openvibers/openvibe.ai Commits
What AI can make: one public catalogue built from the registered workflows and tasks (#424)
Changes
@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0"@@@ -1543,9 +1543,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.384.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",-- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",++ "version": "0.387.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",++ "integrity": "sha512-EYx2yHYTGAqi2sEHAOb/fGT1u5Yj1/rcxmp6xOkqDOCnWtm4848Gr5akd5DP47JsTkbZ/5GHsjCt3d9hTkCR+w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0"@@@ -9,6 +9,8 @@ const { createAiReadiness, registerAiGauges } = require('./observability');const { runsRouter } = require('./api/runs');const { actionsRouter } = require('./api/actions');const { sendError, providerSecret } = require('./util');++const creations = require('./creations');++const home = require('./home');const openapi = require('./openapi');const { adminRouter } = require('./api/admin');const { CAPS } = require('./auth');@@@ -46,8 +48,23 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.const publicCorsPaths = new Set(['/stats', '/release.json']);++ // The creation catalogue is public data for every origin: an app or a page may read it without a++ // token, so it answers `*` rather than the exact-origin list above.++ const openCorsPaths = new Set(['/api/v1/creations', '/api/v1/creations.md']);const corsOrigins = new Set((config.cors && config.cors.origins) || []);app.use((req, res, next) => {++ if (openCorsPaths.has(req.path)) {++ if (req.method !== 'GET' && req.method !== 'OPTIONS') return next();++ res.setHeader('Access-Control-Allow-Origin', '*');++ res.vary('Origin');++ if (req.method === 'OPTIONS') {++ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');++ res.setHeader('Access-Control-Allow-Headers', 'Accept');++ res.setHeader('Access-Control-Max-Age', '600');++ return res.sendStatus(204);++ }++ return next();++ }if (!publicCorsPaths.has(req.path) || (req.method !== 'GET' && req.method !== 'OPTIONS')) return next();const origin = req.get('Origin');const allowed = Boolean(origin) && corsOrigins.has(origin);@@@ -92,6 +109,31 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, pres.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });});++ // What AI can make (ai.creation-list@1): every creation that is live now, built from the registered++ // workflows and tasks. Public and cached for five minutes; the Markdown twin is the same list for AI++ // readers and a page that wants plain text. A workflow that is not active leaves the list.++ async function creationList() {++ const [workflows, tasks] = await Promise.all([registry.listWorkflows(), choices ? choices.tasks() : []]);++ return creations.catalogue({ workflows, tasks });++ }++ const sendCreations = (markdown) => async (req, res, next) => {++ try {++ const list = await creationList();++ const generatedAt = new Date(clock.now()).toISOString();++ res.setHeader('Cache-Control', 'public, max-age=300');++ if (markdown || req.query.format === 'markdown') return res.set('Content-Type', 'text/markdown; charset=utf-8').send(creations.renderMarkdown(list, { generatedAt }));++ return res.json({ creations: list, count: list.length, generated_at: generatedAt });++ } catch (err) { next(err); }++ };++ app.get('/api/v1/creations', sendCreations(false));++ app.get('/api/v1/creations.md', sendCreations(true));++ app.get('/llms.txt', async (req, res, next) => {++ try {++ res.setHeader('Cache-Control', 'public, max-age=300');++ res.type('text/plain; charset=utf-8').send(home.llmsTxt({ siteUrl: config.baseUrl, creations: await creationList() }));++ } catch (err) { next(err); }++ });++// Readiness (openvibe-shared/ready): 503 when the database, the workflows or the Network key// fail; provider configuration is optional and degrades it (see observability.js).const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });@@@ -276,6 +318,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry','POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}','GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',++ 'GET /api/v1/creations what AI can make (public, cached, CORS open); .md for Markdown',++ 'GET /llms.txt the same catalogue for AI readers','GET /api/health, /api/ready, /release.json','GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',`GET ${EXCHANGE}/privacy/keys your own provider keys and session cookies`,@@@ -296,12 +340,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, papp.get('/index.md', moved('/index.md'));app.get('/stats.md', moved('/models.md'));app.get('/updates.md', moved('/status.md'));-- for (const p of ['/terms', '/privacy', '/dmca', '/sitemap.xml', '/llms.txt', '/llms-full.txt']) app.get(p, moved(p));++ for (const p of ['/terms', '/privacy', '/dmca', '/sitemap.xml', '/llms-full.txt']) app.get(p, moved(p));// Discovery: the human site is Exchange, so this origin asks crawlers to leave it alone and names the map// that matters. Machine routes (the API, release.json, a run's receipt) are not crawled anyway.app.get('/robots.txt', (_req, res) => {-- res.type('text/plain').send(`User-agent: *\nDisallow: /\nDisallow: /runs\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\n# The human site is OpenVibe.Exchange; the API stays here.\nSitemap: ${EXCHANGE}/sitemap.xml\n`);++ res.type('text/plain').send(`User-agent: *\nAllow: /llms.txt\nAllow: /api/v1/creations\nAllow: /api/v1/creations.md\nDisallow: /\nDisallow: /runs\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\n# The human site is OpenVibe.Exchange; the API stays here.\nSitemap: ${EXCHANGE}/sitemap.xml\n`);});// A browser asking for a page that is not here goes to the Exchange home: the human site moved, and annew file mode 100644@@@ -0,0 +1,206 @@++'use strict';++/**++ * The catalogue of things AI can make (owner, 11-09, item 45): one entry per creation, built from the++ * registered workflows and tasks rather than a hand-kept list.++ *++ * catalogue({ workflows, tasks }) the live entries, for GET /api/v1/creations++ * renderMarkdown(creations) the same list as Markdown, for GET /api/v1/creations.md++ * problems({ workflows, tasks }) [] when the catalogue and the registry agree, else every drift++ *++ * An entry lives on the workflow that does the work: its definition carries `metadata.creation`, so++ * registering the workflow registers its catalogue entry. A creation that is a task rather than a++ * workflow (Design's icon drawing) is declared in TASK_CREATIONS and must name a registered task.++ *++ * The check is deliberately two-way:++ * - every workflow in a product namespace has a catalogue entry or `metadata.internal: true`, so a++ * new person-facing workflow cannot be registered without saying what it makes (or that it is not++ * a creation);++ * - every entry names a workflow or task that exists, and an entry on a workflow names that workflow.++ *++ * Only live things are listed: a workflow whose registry status is not `active`, or a task that is gone++ * or whose workflow is not active, is left out. The platform namespaces (live, network, ai, chat,++ * research) are not product surfaces and need no entry.++ */++++// The product namespaces: a workflow here is something a person uses, so it declares what it makes.++const CATALOGUE_NAMESPACES = new Set(['blog', 'codes', 'coupons', 'deals', 'food', 'games', 'media', 'meme', 'moderation', 'news', 'pics', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki']);++++const PRODUCTS = ['ai', 'blog', 'codes', 'community', 'design', 'food', 'games', 'live', 'media', 'meme', 'news', 'pics', 'quest', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki'];++++const PRODUCT_NAMES = {++ ai: 'AI', blog: 'Blog', codes: 'Codes', community: 'Community', design: 'Design', food: 'Food', games: 'Games',++ live: 'Live', media: 'Media', meme: 'Meme', news: 'News', pics: 'Pics', quest: 'Quest', rent: 'Rent',++ reviews: 'Reviews', space: 'Space', tools: 'Tools', trade: 'Trade', wiki: 'Wiki',++};++++const ID_RE = /^[a-z][a-z0-9-]{1,63}$/;++const KEY_RE = /^[a-z][a-z0-9_-]*\.[a-z0-9_.-]{1,100}$/;++const ENTRY_FIELDS = ['id', 'product', 'name', 'gives', 'returns', 'access', 'costs_coins', 'start_url'];++++// Creations that are a registered task rather than a workflow (rich-content plan §1). The task names the++// workflow it runs, so the entry is listed only while both the task and that workflow are live.++const TASK_CREATIONS = [++ {++ id: 'icon',++ product: 'design',++ name: 'Draw an icon',++ gives: 'A name for the icon and what it stands for.',++ returns: 'Up to four line drawings in OpenVibe\'s hand, each with its solid twin, checked before you see them.',++ task: 'design.icon',++ workflow: 'ai.chat',++ access: { guest: true, signed_in: true, floor_18: false },++ costs_coins: true,++ start_url: 'https://openvibe.design/icons',++ },++];++++/** One creation as the API answers it: the catalogue fields, with the workflow and task it names. */++function entryOf(source, { workflow = null, task = null } = {}) {++ return {++ id: source.id,++ product: source.product,++ name: source.name,++ gives: source.gives,++ returns: source.returns,++ workflow: source.workflow !== undefined ? source.workflow : workflow,++ task: source.task !== undefined ? source.task : task,++ access: { guest: Boolean(source.access && source.access.guest), signed_in: Boolean(source.access && source.access.signed_in), floor_18: Boolean(source.access && source.access.floor_18) },++ costs_coins: Boolean(source.costs_coins),++ start_url: source.start_url,++ };++}++++/** What is wrong with one entry, as plain sentences ([] when it is complete). */++function entryProblems(entry, where) {++ const bad = [];++ if (!entry || typeof entry !== 'object') return [`${where}: not an object`];++ for (const k of Object.keys(entry)) if (!ENTRY_FIELDS.includes(k) && !['workflow', 'task'].includes(k)) bad.push(`${where}: unknown field ${k}`);++ if (!ID_RE.test(String(entry.id || ''))) bad.push(`${where}: id must be lowercase letters, digits and dashes`);++ if (!PRODUCTS.includes(entry.product)) bad.push(`${where}: unknown product ${entry.product}`);++ if (typeof entry.name !== 'string' || entry.name.length < 3 || entry.name.length > 80) bad.push(`${where}: name must be 3-80 characters`);++ if (typeof entry.gives !== 'string' || entry.gives.length < 3 || entry.gives.length > 300) bad.push(`${where}: gives must be 3-300 characters`);++ if (typeof entry.returns !== 'string' || entry.returns.length < 3 || entry.returns.length > 300) bad.push(`${where}: returns must be 3-300 characters`);++ if (typeof entry.costs_coins !== 'boolean') bad.push(`${where}: costs_coins must be a boolean`);++ if (typeof entry.start_url !== 'string' || !/^https:\/\/[^\s]+$/.test(entry.start_url)) bad.push(`${where}: start_url must be an https URL`);++ const a = entry.access;++ if (!a || typeof a.guest !== 'boolean' || typeof a.signed_in !== 'boolean' || typeof a.floor_18 !== 'boolean') bad.push(`${where}: access needs guest, signed_in and floor_18 booleans`);++ else if (!a.guest && !a.signed_in) bad.push(`${where}: nobody may use it (access.guest and access.signed_in are both false)`);++ if (entry.workflow != null && !KEY_RE.test(String(entry.workflow))) bad.push(`${where}: workflow is not a workflow key`);++ if (entry.task != null && !KEY_RE.test(String(entry.task))) bad.push(`${where}: task is not a task key`);++ if (entry.workflow == null && entry.task == null) bad.push(`${where}: names neither a workflow nor a task`);++ return bad;++}++++function sortKey(entry) {++ return `${String(entry.product)} ${String(entry.name)} ${String(entry.id)}`;++}++++/**++ * The live catalogue. `workflows` are registry records (key, status, metadata); `tasks` are the registered++ * tasks (name, workflow). Nothing is listed unless the workflow doing the work is active.++ */++function catalogue({ workflows = [], tasks = [] } = {}) {++ const live = new Map(workflows.filter((w) => w && w.status === 'active').map((w) => [w.key, w]));++ const taskByName = new Map(tasks.filter((t) => t && t.name).map((t) => [t.name, t]));++ const seen = new Set();++ const out = [];++++ for (const w of live.values()) {++ const c = w.metadata && w.metadata.creation;++ if (!c) continue;++ if (c.workflow !== w.key) continue; // a typo: the check names it, the API leaves it out++ const entry = entryOf(c, { workflow: w.key });++ if (entryProblems(entry, w.key).length || seen.has(entry.id)) continue;++ seen.add(entry.id);++ out.push(entry);++ }++++ for (const c of TASK_CREATIONS) {++ const t = taskByName.get(c.task);++ if (!t) continue;++ if (c.workflow && !live.has(c.workflow)) continue;++ const entry = entryOf(c);++ if (entryProblems(entry, c.id).length || seen.has(entry.id)) continue;++ seen.add(entry.id);++ out.push(entry);++ }++++ return out.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));++}++++/** Every way the catalogue and the registry disagree, as plain sentences ([] when they agree). */++function problems({ workflows = [], tasks = [] } = {}) {++ const bad = [];++ const workflowKeys = new Set(workflows.filter((w) => w && w.key).map((w) => w.key));++ const taskNames = new Set(tasks.filter((t) => t && t.name).map((t) => t.name));++ const ids = new Map();++++ for (const w of workflows) {++ if (!w || !w.key) continue;++ const c = w.metadata && w.metadata.creation;++ if (!c) {++ if (CATALOGUE_NAMESPACES.has(String(w.key).split('.')[0]) && !(w.metadata && w.metadata.internal)) {++ bad.push(`${w.key}: a product workflow needs metadata.creation (what a person can make) or metadata.internal: true`);++ }++ continue;++ }++ const entry = entryOf(c);++ const where = `${w.key} (metadata.creation)`;++ bad.push(...entryProblems(entry, where));++ if (c.workflow !== w.key) bad.push(`${where}: workflow must be "${w.key}" (it names ${JSON.stringify(c.workflow)})`);++ if (c.task != null && !taskNames.has(c.task)) bad.push(`${where}: task ${c.task} is not registered`);++ if (ids.has(entry.id)) bad.push(`${where}: id ${entry.id} is also used by ${ids.get(entry.id)}`);++ else ids.set(entry.id, w.key);++ }++++ for (const c of TASK_CREATIONS) {++ const entry = entryOf(c);++ const where = `${c.id} (TASK_CREATIONS)`;++ bad.push(...entryProblems(entry, where));++ if (!taskNames.has(c.task)) bad.push(`${where}: task ${c.task} is not registered`);++ if (c.workflow != null && !workflowKeys.has(c.workflow)) bad.push(`${where}: workflow ${c.workflow} does not exist`);++ if (ids.has(entry.id)) bad.push(`${where}: id ${entry.id} is also used by ${ids.get(entry.id)}`);++ else ids.set(entry.id, 'TASK_CREATIONS');++ }++++ return bad;++}++++const ACCESS = (a) => (a.guest && a.signed_in ? 'Anyone, signed in or not' : a.guest ? 'Anyone' : 'Signed in');++++/** The same catalogue as Markdown, grouped by product: for GET /api/v1/creations.md and /llms.txt. */++function renderMarkdown(creations, { generatedAt = null, heading = true } = {}) {++ const byProduct = new Map();++ for (const c of creations) {++ if (!byProduct.has(c.product)) byProduct.set(c.product, []);++ byProduct.get(c.product).push(c);++ }++ const lines = [];++ if (heading) {++ lines.push(++ '# What AI can make on OpenVibe',++ '',++ `${creations.length} creation${creations.length === 1 ? '' : 's'}, built from the workflows and tasks that are live now${generatedAt ? ` (as of ${generatedAt})` : ''}.`,++ '',++ 'The same list as data: GET https://ai.openvibe.services/api/v1/creations (public, cached, CORS open) and its contract `ai.creation-list@1`.',++ '',++ );++ }++ const productName = (id) => PRODUCT_NAMES[id] || id;++ for (const [product, list] of [...byProduct.entries()].sort((a, b) => productName(a[0]).localeCompare(productName(b[0])))) {++ lines.push(`## ${PRODUCT_NAMES[product] || product}`, '');++ for (const c of list.sort((a, b) => a.name.localeCompare(b.name))) {++ lines.push(`### ${c.name}`, '');++ lines.push(`- You give it: ${c.gives}`);++ lines.push(`- You get back: ${c.returns}`);++ lines.push(`- Who: ${ACCESS(c.access)}${c.access.floor_18 ? ', 18+ only' : ''}`);++ lines.push(`- Cost: ${c.costs_coins ? 'a run may use paid AI after your free allowance' : 'free'}`);++ lines.push(`- Start here: ${c.start_url}`);++ lines.push(`- Behind it: ${c.workflow ? `workflow \`${c.workflow}\`` : ''}${c.workflow && c.task ? ' · ' : ''}${c.task ? `task \`${c.task}\`` : ''}`);++ lines.push('');++ }++ }++ return lines.join('\n');++}++++module.exports = { catalogue, problems, renderMarkdown, CATALOGUE_NAMESPACES, PRODUCTS, PRODUCT_NAMES, TASK_CREATIONS };@@@ -8,7 +8,7 @@** renderHome({ siteUrl, stats }) the whole document (siteUrl: the public origin, config.baseUrl; stats: { days, rows })* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)-- * llmsTxt({ siteUrl }) /llms.txt, the same facts as plain text++ * llmsTxt({ siteUrl, creations }) /llms.txt, the same facts as plain text, with the live catalogue*/const shell = require('openvibe-shared/shell');const frame = require('openvibe-shared/frame');@@@ -16,6 +16,7 @@ const showcase = require('openvibe-shared/showcase');const ovServe = require('openvibe-shared/serve');const appIcon = require('openvibe-shared/app-icon');const seo = require('openvibe-shared/seo');++const { renderMarkdown } = require('./creations');const SITE_NAME = 'OpenVibe.AI';const NETWORK_URL = 'https://openvibe.network';@@@ -411,7 +412,7 @@ function llmsFullTxt({ siteUrl }) {// llmstxt.org: the pages as links, each of this site's own at its Markdown twin (AGT-5, openvibe-shared 4.108.0's markdown// mode); the developer-app facts as plain lines; ## Optional last.--function llmsTxt({ siteUrl }) {++function llmsTxt({ siteUrl, creations = [] }) {const head = seo.llmsTxt({name: SITE_NAME,summary: DESCRIPTION,@@@ -419,8 +420,9 @@ function llmsTxt({ siteUrl }) {base: siteUrl,sections: [{ title: 'Start here', links: [-- { title: 'Home', url: `${siteUrl}/` },-- { title: 'Prices and measured latency per provider and model', url: `${siteUrl}/stats` },++ { title: 'What AI can make (the same catalogue as data; public, cached, CORS open)', url: `${siteUrl}/api/v1/creations` },++ { title: 'What AI can make, as Markdown', url: `${siteUrl}/api/v1/creations.md` },++ { title: 'The human site (home, prices, status, console)', url: 'https://openvibe.exchange/' },{ title: 'Developer guide (tokens, limits, billing, sandbox)', url: GUIDE },{ title: 'API reference (OpenAPI, generated from the contracts)', url: API_DOCS_URL },{ title: 'Create a project and an app', url: PROJECTS_URL },@@@ -430,10 +432,7 @@ function llmsTxt({ siteUrl }) {const optional = seo.llmsTxt({ name: '', sections: [], optional: [{ title: 'Owner AI operator console', url: 'https://openvibe.exchange/console' },{ title: 'Your keys and accounts', url: 'https://openvibe.exchange/privacy/keys' },-- { title: 'What shipped on OpenVibe.AI', url: `${siteUrl}/updates` },{ title: 'Source (AGPL-3.0)', url: SOURCE },-- { title: 'Sitemap', url: `${siteUrl}/sitemap.xml` },-- { title: 'Full text for language models', url: `${siteUrl}/llms-full.txt` },], markdown: true, base: siteUrl }).split('\n## Optional\n')[1];return [head.replace(/\n+$/, ''),@@@ -447,6 +446,12 @@ function llmsTxt({ siteUrl }) {'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.','- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.','',++ '## What AI can make',++ '',++ `The live catalogue, built from the registered workflows and tasks: ${creations.length} creation${creations.length === 1 ? '' : 's'} today. Each line names what a person gives it, what comes back, who may use it, whether it can cost coins and where to start.`,++ '',++ renderMarkdown(creations, { heading: false }),++ '',`## Optional\n${optional}`,].join('\n');}@@@ -336,7 +336,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {if (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, refusal, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, refusal, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };}if (require.main === module) {@@@ -89,6 +89,37 @@ function route(method, p, operation) {(PATHS[p] = PATHS[p] || {})[method] = operation;}++// ── What AI can make (owner, 11-09, item 45) ────────────────────────────────────────────────++const creation = {++ type: 'object', additionalProperties: false,++ required: ['id', 'product', 'name', 'gives', 'returns', 'workflow', 'task', 'access', 'costs_coins', 'start_url'],++ properties: {++ id: str({ pattern: '^[a-z][a-z0-9-]{1,63}$' }),++ product: str({ enum: ['ai', 'blog', 'codes', 'community', 'design', 'food', 'games', 'live', 'media', 'meme', 'news', 'pics', 'quest', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki'] }),++ name: str({ maxLength: 80 }), gives: str({ maxLength: 300 }), returns: str({ maxLength: 300 }),++ workflow: nullable('string'), task: nullable('string'),++ access: {++ type: 'object', additionalProperties: false, required: ['guest', 'signed_in', 'floor_18'],++ properties: { guest: { type: 'boolean' }, signed_in: { type: 'boolean' }, floor_18: { type: 'boolean' } },++ },++ costs_coins: { type: 'boolean' }, start_url: str({ format: 'uri' }),++ },++};++const creationList = {++ type: 'object', additionalProperties: false, required: ['creations', 'count', 'generated_at'],++ properties: { creations: arrayOf(creation), count: integer({ minimum: 0 }), generated_at: str({ format: 'date-time' }) },++};++route('get', '/creations', {++ operationId: 'listCreations', summary: 'What AI can make: every creation that is live now', tags: ['creations'], security: [],++ description: 'Public, cached for five minutes and open to every origin (Access-Control-Allow-Origin: *). One entry per thing AI can make, built from the registered workflows and tasks (ai.creation-list@1): the product, a plain name, what the person gives it and what comes back, the workflow or task behind it, who may use it, whether it can cost coins and where to start. Only a workflow whose status is active is listed. The same list as Markdown is GET /api/v1/creations.md, or this path with ?format=markdown, and it is in /llms.txt.',++ responses: { 200: json('The live catalogue', creationList), ...errors([429]) },++});++route('get', '/creations.md', {++ operationId: 'listCreationsMarkdown', summary: 'What AI can make, as Markdown', tags: ['creations'], security: [],++ description: 'The same live catalogue grouped by product, for AI readers and pages that want plain text (ai.creation-list@1).',++ responses: { 200: { description: 'The catalogue as Markdown', content: { 'text/markdown': { schema: { type: 'string' } } } }, ...errors([429]) },++});++// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────route('get', '/tasks', {operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],@@@ -59,6 +59,7 @@ const workflow = {output_schema: template.output_schema,default_route: 'default.json',steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],++ metadata: { internal: true },};module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };@@@ -154,6 +154,15 @@ const workflows = [output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',++ metadata: {++ creation: {++ id: 'image', product: 'pics', name: 'Make an image', workflow: 'ai.image',++ gives: 'A description of the image, its size and how many you want.',++ returns: 'One to four images.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.pics/',++ },++ },},];@@@ -23,7 +23,16 @@ const workflows = [{ key: 'content.devblog', name: 'Draft a devblog from mergedinput_schema: { ...obj({ site: str(80), from: str(10), to: str(10), sources: SOURCES(1, 200), stories: arr({ type: 'object' }, 1, 8), screenshots: arr(obj({ media_id: str(80), alt: str(300), caption: str(500), source: { type: 'integer', minimum: 0 } }), 0, 24), voice: str(6000) }) },output_schema: obj({ title: str(80), summary: str(2000), names: arr(str(80), 5, 5), blocks: { type: 'object' }, citations: arr({ type: 'integer', minimum: 0 }, 1, 200), checks: arr(str(500), 0, 30), steps: arr(str(100), 1, 10) }),steps: [{ kind: 'devblog', cite: true }], default_route: 'background.json', cache_mode: 'none',-- metadata: { seed: true, draft: true, task: 'blog.devblog.write', tools: ['changelog.stories', 'site.screenshot', 'facts.check'], max_model_calls: 5 } }];++ metadata: {++ seed: true, draft: true, task: 'blog.devblog.write', tools: ['changelog.stories', 'site.screenshot', 'facts.check'], max_model_calls: 5,++ creation: {++ id: 'devblog', product: 'blog', name: 'Write my project\'s devblog', workflow: 'content.devblog', task: 'blog.devblog.write',++ gives: 'The merged changes for one site over a period, and the stories to tell.',++ returns: 'A named devblog with sections, hero art and the full patch notes folded in.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.blog/write',++ },++ } }];const SLOP = /\b(unleashed|supercharged|level up|game changer|next level)\b/i;function issuesOf(draft, input) {const issues = [];@@@ -78,6 +78,15 @@ const workflows = [// Not cached: a result made while the local model was down (or before a budget) must not outlive that, and// callers keep what they asked for (Live's vod_ai_state/clip_ai_state).cache_mode: 'none',++ metadata: {++ creation: {++ id: 'recording-analysis', product: 'media', name: 'Analyse a recording', workflow: 'media.analyze',++ gives: 'A video, clip or audio file.',++ returns: 'Its scenes, speech, highlights and a short overview, measured on our own machines first.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.media/',++ },++ },},];@@@ -15,6 +15,15 @@ const workflow = {description: 'Generate a handful of original, short caption ideas for a selected meme template.',input_schema: template.input_schema, output_schema: template.output_schema, default_route: 'meme.caption',steps: [{ kind: 'llm', template: 'meme.caption', operation: 'generate', output: 'json', params: { max_tokens: 700, temperature: 0.85 } }],++ metadata: {++ creation: {++ id: 'meme-caption', product: 'meme', name: 'Write meme caption ideas', workflow: 'meme.caption',++ gives: 'A meme format and the situation you want to joke about.',++ returns: 'Three to five original caption ideas for that format.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.meme/',++ },++ },};module.exports = { templates: [template], workflows: [workflow] };@@@ -83,6 +83,15 @@ const workflows = [}),default_route: 'wiki.write',steps: [{ kind: 'llm', template: 'wiki.generate_space', operation: 'generate', output: 'json', prepare: 'wiki.space', cite: true, params: { max_tokens: 4000, temperature: 0.3, timeout_ms: 150000 } }],++ metadata: {++ creation: {++ id: 'wiki', product: 'wiki', name: 'Make a wiki', workflow: 'wiki.generate_space',++ gives: 'A topic, the sources to build it from, and who it is for.',++ returns: 'A wiki plan: its title, a summary and the pages to make, each with an outline and its citations.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.wiki/spaces',++ },++ },},{key: 'wiki.generate_page', name: 'Write a wiki page', namespace: 'wiki',@@@ -99,6 +108,15 @@ const workflows = [}),default_route: 'wiki.write',steps: [{ kind: 'llm', template: 'wiki.generate_page', operation: 'generate', output: 'json', prepare: 'wiki.page', cite: true, params: { max_tokens: 7000, temperature: 0.4, timeout_ms: 170000 } }],++ metadata: {++ creation: {++ id: 'wiki-article', product: 'wiki', name: 'Write a wiki article', workflow: 'wiki.generate_page',++ gives: 'A page title, the wiki it belongs to, and the sources to cite.',++ returns: 'A cited article: a lead, sections, an infobox and the pictures that fit.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.wiki/spaces',++ },++ },},{// The wiki writer (rich-content plan §5): research, then every sentence cited and the infobox from facts.@@@ -122,12 +140,30 @@ const workflows = [{ kind: 'research' },{ kind: 'llm', template: 'content.wiki', operation: 'generate', output: 'json', prepare: 'content.wiki', cite: true, params: { max_tokens: 8000, temperature: 0.4, timeout_ms: 180000 } },],++ metadata: {++ creation: {++ id: 'wiki-research', product: 'wiki', name: 'Research and write a wiki article', workflow: 'content.wiki',++ gives: 'A title and what the article should cover; it finds its own sources.',++ returns: 'A cited article as sentences you can check, filed as a proposal for a person to review.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.wiki/spaces',++ },++ },},{key: 'blog.draft_post', name: 'Draft a blog post', namespace: 'blog',input_schema: OBJ({ topic: STR(300), brief: STR(4000), tone: STR(200), audience: STR(300), sources: SOURCES(0) }, ['topic']),output_schema: OBJ({ title: STR(200), dek: STR(400), body_markdown: STR(40000), tags: ARR(STR(40), 10), citations: CITES, gaps: GAPS }),default_route: 'background.json',++ metadata: {++ creation: {++ id: 'blog-post', product: 'blog', name: 'Write a blog post', workflow: 'blog.draft_post',++ gives: 'A topic, and optionally a brief, a tone and who it is for.',++ returns: 'A Markdown draft with a title, a standfirst and cited sources, saved as a draft you can edit.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.blog/write',++ },++ },},{// OpenVibe.Blog's AI desks (server/desks.js): a digest of what a desk's sources published, each section cited.@@@ -135,6 +171,7 @@ const workflows = [input_schema: OBJ({ desk: STR(120), about: STR(400), period: STR(120), sources: SOURCES(2) }, ['desk', 'period', 'sources']),output_schema: OBJ({ title: STR(200), dek: STR(400), sections: ARR(OBJ({ heading: STR(160), paragraphs: ARR(CLAIM, 8) }), 6), citations: CITES, gaps: GAPS }),default_route: 'background.json',++ metadata: { internal: true },},{key: 'news.summarize_story', name: 'Summarize a news story', namespace: 'news',@@@ -143,18 +180,45 @@ const workflows = [input_schema: OBJ({ topic: STR(300), tone: { enum: ['brief', 'neutral', 'explained'] }, sources: SOURCES(1) }, ['sources']),output_schema: OBJ({ headline: STR(200), summary: STR(3000), key_points: ARR(CLAIM, 20), timeline: ARR(OBJ({ when: STR(60), what: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),default_route: 'background.json',++ metadata: {++ creation: {++ id: 'news-story', product: 'news', name: 'Summarize a news story', workflow: 'news.summarize_story',++ gives: 'The source reports for one story, and whether you want it brief or explained.',++ returns: 'A headline, a summary, the key points and a timeline, every claim cited.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.news/stories',++ },++ },},{key: 'news.compare_perspectives', name: 'Compare perspectives on a story', namespace: 'news',input_schema: OBJ({ topic: STR(300), sources: SOURCES(2) }, ['topic', 'sources']),output_schema: OBJ({ question: STR(300), perspectives: ARR(OBJ({ label: STR(120), summary: STR(2000), citations: CITES }), 10), agreements: ARR(CLAIM, 10), disagreements: ARR(CLAIM, 10), citations: CITES, gaps: GAPS }),default_route: 'news.summarize',++ metadata: {++ creation: {++ id: 'news-perspectives', product: 'news', name: 'Compare how sources tell a story', workflow: 'news.compare_perspectives',++ gives: 'A question and the sources from each side of it.',++ returns: 'Each perspective in its own terms, where they agree and where they differ.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.news/stories',++ },++ },},{key: 'reviews.summarize_entity', name: 'Summarize review signals for an entity', namespace: 'reviews',input_schema: OBJ({ entity: OBJ({ name: STR(200), type: STR(80) }, ['name']), sources: SOURCES(1) }, ['entity', 'sources']),output_schema: OBJ({ summary: STR(2000), themes: ARR(STR(80), 12), pros: ARR(CLAIM, 12), cons: ARR(CLAIM, 12), coverage: { enum: ['weak', 'moderate', 'strong'] }, citations: CITES, gaps: GAPS }),default_route: 'reviews.summarize',++ metadata: {++ creation: {++ id: 'review-summary', product: 'reviews', name: 'Summarize what reviewers say', workflow: 'reviews.summarize_entity',++ gives: 'An entity and the review signals written about it.',++ returns: 'The themes, pros and cons, each cited, and how strong the coverage is.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.reviews/',++ },++ },},{key: 'deals.enrich_deal', name: 'Enrich a deal', namespace: 'deals',@@@ -170,6 +234,7 @@ const workflows = [summary: STR(1000), citations: CITES, gaps: GAPS,}),default_route: 'deals.enrich',++ metadata: { internal: true },},{key: 'coupons.extract_coupon', name: 'Extract coupons', namespace: 'coupons',@@@ -179,6 +244,7 @@ const workflows = [citations: CITES, gaps: GAPS,}),default_route: 'coupons.extract',++ metadata: { internal: true },},{key: 'trade.summarize_market_context', name: 'Summarize market context (informational)', namespace: 'trade',@@@ -191,18 +257,45 @@ const workflows = [citations: CITES, gaps: GAPS,}),default_route: 'background.json',++ metadata: {++ creation: {++ id: 'market-context', product: 'trade', name: 'Summarize a market\'s latest filings', workflow: 'trade.summarize_market_context',++ gives: 'An instrument and the sourced market and filing context.',++ returns: 'Sourced observations with their dates and a plain informational disclaimer; never advice.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.trade/',++ },++ },},{key: 'codes.generate_docs', name: 'Generate documentation from code', namespace: 'codes',input_schema: OBJ({ project: STR(200), audience: STR(200), sources: SOURCES(1) }, ['project', 'sources']),output_schema: OBJ({ title: STR(200), overview: STR(4000), sections: ARR(OBJ({ heading: STR(200), body_markdown: STR(20000), citations: CITES }), 40), citations: CITES, gaps: GAPS }),default_route: 'codes.generate_docs',++ metadata: {++ creation: {++ id: 'project-docs', product: 'codes', name: 'Write docs for my project', workflow: 'codes.generate_docs',++ gives: 'The project and its source files.',++ returns: 'An overview and sections in Markdown, each citing the files it describes.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.codes/',++ },++ },},{key: 'games.generate_lore', name: 'Generate game lore', namespace: 'games',input_schema: OBJ({ world: STR(200), prompt: STR(4000), sources: SOURCES(0) }, ['world', 'prompt']),output_schema: OBJ({ title: STR(200), lore_markdown: STR(20000), entities: ARR(OBJ({ name: STR(120), kind: STR(60), description: STR(1000) }), 30), contradictions: ARR(STR(500), 10), citations: CITES, gaps: GAPS }),default_route: 'games.generate_lore',++ metadata: {++ creation: {++ id: 'game-lore', product: 'games', name: 'Write lore for my game world', workflow: 'games.generate_lore',++ gives: 'Your world, its canon sources and what you want written.',++ returns: 'Lore in Markdown with its entities, and every clash with canon named.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.games/',++ },++ },},{key: 'tools.describe', name: 'Describe a tool', namespace: 'tools',@@@ -215,6 +308,15 @@ const workflows = [citations: CITES, gaps: GAPS,}),default_route: 'tools.describe',++ metadata: {++ creation: {++ id: 'tool-page', product: 'tools', name: 'Describe a tool for its users', workflow: 'tools.describe',++ gives: 'A tool\'s descriptor: what it is and what it takes.',++ returns: 'A summary, how to use it, every input and output, its limits and an FAQ.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.tools/',++ },++ },},{key: 'food.suggest_recipes', name: 'Suggest recipes from a pantry', namespace: 'food',@@@ -228,6 +330,15 @@ const workflows = [citations: CITES, gaps: GAPS,}),default_route: 'background.json',++ metadata: {++ creation: {++ id: 'recipes', product: 'food', name: 'Suggest recipes from my pantry', workflow: 'food.suggest_recipes',++ gives: 'What is in your pantry, how many people you are cooking for, and what you feel like.',++ returns: 'Up to three recipes with everyday amounts and short steps; any missing ingredient is named.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.food/',++ },++ },},{key: 'rent.screen_listing', name: 'Screen a rental listing for scam signs', namespace: 'rent',@@@ -243,6 +354,15 @@ const workflows = [citations: CITES,}),default_route: 'rent.screen', cache_mode: 'private',++ metadata: {++ creation: {++ id: 'rental-check', product: 'rent', name: 'Check a rental listing for scam signs', workflow: 'rent.screen_listing',++ gives: 'A rental listing as you found it.',++ returns: 'Allow or review, the exact words behind each scam sign, and a plain reason.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.rent/',++ },++ },},{key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',@@@ -256,12 +376,13 @@ const workflows = [citations: CITES,}),default_route: 'moderation.classify', cache_mode: 'private',++ metadata: { internal: true },},].map(w => ({cache_mode: 'private',...w,steps: w.steps || [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' || w.key === 'rent.screen_listing' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],-- metadata: { draft: true, seed: true },++ metadata: { draft: true, seed: true, ...(w.metadata || {}) },}));// Food's cupboard by photo or receipt (OpenVibe.Food /pantry/photo): a picture, not sources, so it has its own step.@@@ -285,7 +406,7 @@ workflows.push({default_route: 'food.vision',// A person's photo: never shared between callers (the stored run keeps only the image's hash, server/runs.js).cache_mode: 'none',-- metadata: { seed: true },++ metadata: { seed: true, internal: true },});// OpenVibe.Pics: alt text, a caption and tags for one picture (accessibility, embeds and finding your own pictures).@@@ -305,7 +426,16 @@ workflows.push({default_route: 'background.json',// A person's picture: never shared between callers (the stored run keeps only the image's hash).cache_mode: 'none',-- metadata: { seed: true },++ metadata: {++ seed: true,++ creation: {++ id: 'picture-alt', product: 'pics', name: 'Describe a picture', workflow: 'pics.describe_image',++ gives: 'A picture, and optionally the title you gave it.',++ returns: 'Alt text, a caption and up to six tags, ready to paste.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.pics/',++ },++ },});// OpenVibe.Blog's satire desk ("Is This Real?"): invented on purpose, so it is not a sourced evidence package and has its@@@ -325,7 +455,7 @@ workflows.push({steps: [{ kind: 'llm', template: 'blog.write_satire', operation: 'generate', output: 'json', params: { max_tokens: 2400, temperature: 0.9 } }],default_route: 'background.json',cache_mode: 'private',-- metadata: { seed: true },++ metadata: { seed: true, internal: true },});module.exports = { templates, workflows };@@@ -106,7 +106,17 @@ const workflows = [input_schema: OBJ({ prompt: STR(4000), name: STR(40), current: ARR(FILE, MAX_FILES), components: STR(20000) }, ['prompt']),output_schema: OBJ({ name: STR(40), description: STR(400), instructions: STR(4000), capabilities: ARR({ type: 'string', enum: CAPABILITIES }, 4), files: ARR(FILE, MAX_FILES) }),steps: [{ kind: 'llm', template: 'space.app.build', operation: 'generate', output: 'json', prepare: 'space.app.build', postprocess: 'space.app.build', params: { max_tokens: 16000, timeout_ms: 180000 } }],-- default_route: 'space.build', cache_mode: 'none', metadata: { seed: true },++ default_route: 'space.build', cache_mode: 'none',++ metadata: {++ seed: true,++ creation: {++ id: 'space-app', product: 'space', name: 'Make a website, game or app', workflow: 'space.app.build',++ gives: 'What you want it to do, and the files when you are changing one.',++ returns: 'A working space: its name, what it does, the AI it may use and every file.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.space/projects',++ },++ },},{key: 'space.app.ask', name: 'Space: a space\'s AI answers a visitor', namespace: 'space',@@@ -114,7 +124,7 @@ const workflows = [input_schema: OBJ({ space: STR(40), instructions: STR(4000), transcript: STR(16000), message: STR(4000) }, ['space', 'message']),output_schema: OBJ({ text: { type: 'string' } }),steps: [{ kind: 'llm', template: 'space.app.ask', operation: 'chat', output: 'text', params: { max_tokens: 800, temperature: 0.7, timeout_ms: 25000 } }],-- default_route: 'space.ask', cache_mode: 'none', metadata: { seed: true },++ default_route: 'space.ask', cache_mode: 'none', metadata: { seed: true, internal: true },},];nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// What AI can make (ai.creation-list@1): the catalogue is built from the registered workflows and tasks,++// is public, cached and open to every origin, has a Markdown twin, and the check fails when the code and++// the registry disagree — a product workflow without an entry, or an entry naming something absent.++const assert = require('assert');++const { boot, request, suite } = require('./helpers');++const creations = require('../server/creations');++++const t = suite('creations');++let h;++++t.test('boot', async () => { h = await boot(); });++++t.test('the list is live, public, cached and open to every origin', async () => {++ const r = await request(h.base, 'GET', '/api/v1/creations', { headers: { Origin: 'https://openvibe.exchange' } });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /application\/json/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ assert.ok(Array.isArray(r.body.creations) && r.body.creations.length >= 10, `entries: ${r.body.creations && r.body.creations.length}`);++ assert.strictEqual(r.body.count, r.body.creations.length);++ assert.ok(Date.parse(r.body.generated_at) > 0);++ const ids = new Set(r.body.creations.map((c) => c.id));++ for (const id of ['blog-post', 'devblog', 'wiki', 'wiki-article', 'space-app', 'image', 'icon', 'recipes', 'rental-check', 'meme-caption']) assert.ok(ids.has(id), `lists ${id}`);++ const blog = r.body.creations.find((c) => c.id === 'blog-post');++ assert.deepStrictEqual(blog, {++ id: 'blog-post', product: 'blog', name: 'Write a blog post',++ gives: 'A topic, and optionally a brief, a tone and who it is for.',++ returns: 'A Markdown draft with a title, a standfirst and cited sources, saved as a draft you can edit.',++ workflow: 'blog.draft_post', task: null,++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.blog/write',++ });++ const icon = r.body.creations.find((c) => c.id === 'icon');++ assert.strictEqual(icon.task, 'design.icon');++ assert.strictEqual(icon.workflow, 'ai.chat');++ assert.strictEqual(icon.access.guest, true, 'the icon drawer lets a guest draw');++});++++t.test('a preflight answers the same open CORS policy', async () => {++ const r = await request(h.base, 'OPTIONS', '/api/v1/creations', { headers: { Origin: 'https://openvibe.exchange', 'Access-Control-Request-Method': 'GET' } });++ assert.strictEqual(r.status, 204);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);++});++++t.test('the Markdown twin is the same list, grouped by product', async () => {++ const md = await request(h.base, 'GET', '/api/v1/creations.md');++ assert.strictEqual(md.status, 200);++ assert.match(md.headers.get('content-type') || '', /^text\/markdown/);++ assert.strictEqual(md.headers.get('access-control-allow-origin'), '*');++ assert.match(md.text, /^# What AI can make on OpenVibe/m);++ assert.ok(md.text.includes('## Blog') && md.text.includes('### Write a blog post'));++ assert.ok(md.text.includes('- Start here: https://openvibe.blog/write'));++ assert.ok(md.text.includes('task `design.icon`'));++ const byQuery = await request(h.base, 'GET', '/api/v1/creations?format=markdown');++ assert.match(byQuery.headers.get('content-type') || '', /^text\/markdown/);++});++++t.test('the registered workflows and tasks agree with the catalogue', async () => {++ const problems = creations.problems({ workflows: await h.registry.listWorkflows(), tasks: await h.choices.tasks() });++ assert.deepStrictEqual(problems, []);++});++++t.test('the code definitions agree before seeding too, so a new workflow cannot ship without a decision', async () => {++ const definitions = ['live', 'core', 'products', 'devblog', 'media', 'chat', 'space', 'research', 'meme', 'changelog']++ .flatMap((name) => require(`../server/workflows/${name}`).workflows);++ const problems = creations.problems({ workflows: definitions.map((w) => ({ ...w, status: 'active' })), tasks: await h.choices.tasks() });++ assert.deepStrictEqual(problems, []);++});++++t.test('a product workflow without an entry fails the check; an entry naming an absent workflow fails too', async () => {++ const workflows = await h.registry.listWorkflows();++ const tasks = await h.choices.tasks();++ const blog = workflows.find((w) => w.key === 'blog.draft_post');++ const without = { ...blog, metadata: { ...blog.metadata, creation: undefined } };++ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? without : w)), tasks }).some((p) => p.includes('blog.draft_post') && p.includes('metadata.creation')));++ const mistyped = { ...blog, metadata: { ...blog.metadata, creation: { ...blog.metadata.creation, workflow: 'blog.not_a_workflow' } } };++ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? mistyped : w)), tasks }).some((p) => p.includes('does not exist') || p.includes('must be')));++ const unnamedTask = { ...blog, metadata: { ...blog.metadata, creation: { ...blog.metadata.creation, task: 'design.not_registered' } } };++ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? unnamedTask : w)), tasks }).some((p) => p.includes('not registered')));++});++++t.test('only what is live is listed: a deprecated workflow leaves the list at once', async () => {++ const workflow = await h.registry.activeWorkflow('blog.draft_post');++ await h.registry.setStatus('workflow', 'blog.draft_post', workflow.version, 'deprecated', { actor: 'test' });++ try {++ const off = await request(h.base, 'GET', '/api/v1/creations');++ assert.ok(!off.body.creations.some((c) => c.id === 'blog-post'), 'the deprecated creation is gone');++ assert.ok(off.body.creations.length >= 10);++ } finally {++ await h.registry.setStatus('workflow', 'blog.draft_post', workflow.version, 'active', { actor: 'test' });++ }++ const on = await request(h.base, 'GET', '/api/v1/creations');++ assert.ok(on.body.creations.some((c) => c.id === 'blog-post'), 'and comes back when active');++});++++t.test('stop', async () => { await h.stop(); });++++t.run();@@@ -54,11 +54,21 @@ t.test('the exact page map, including the Markdown twins and the legal pages', a['/privacy', '/privacy'],['/dmca', '/dmca'],['/sitemap.xml', '/sitemap.xml'],-- ['/llms.txt', '/llms.txt'],['/llms-full.txt', '/llms-full.txt'],]) await moved(p, to);});++t.test('/llms.txt stays on AI and carries the creation catalogue', async () => {++ const r = await request(h.base, 'GET', '/llms.txt', { headers: { Accept: BROWSER }, redirect: 'manual' });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^text\/plain/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.match(r.text, /^# OpenVibe\.AI/m);++ assert.ok(r.text.includes('## What AI can make'), 'names the catalogue section');++ assert.ok(r.text.includes('/api/v1/creations'), 'names the endpoint');++ assert.ok(r.text.includes('Write a blog post'), 'lists a live creation');++});++t.test('an unknown human page goes to the Exchange home; an unknown API path stays a problem document', async () => {await moved('/prices-and-latency', '/');await moved('/some-old-post', '/');@@@ -78,7 +88,6 @@ t.test('console paths redirect to Exchange while machine and receipt routes stayassert.strictEqual(release.status, 200);await moved('/console', '/console');await moved('/console/providers?tab=all', '/console/providers?tab=all');-- await moved('/llms.txt', '/llms.txt');await moved('/auth/login', '/auth/login');await moved('/you', '/privacy/keys');await moved('/you?allow=actor&provider=openai', '/privacy/keys?allow=actor&provider=openai');
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1543,9 +1543,9 @@
@@ -1543,9 +1543,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.384.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
+ "version": "0.387.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
+ "integrity": "sha512-EYx2yHYTGAqi2sEHAOb/fGT1u5Yj1/rcxmp6xOkqDOCnWtm4848Gr5akd5DP47JsTkbZ/5GHsjCt3d9hTkCR+w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -9,6 +9,8 @@ const { createAiReadiness, registerAiGauges } = require('./observability');
@@ -9,6 +9,8 @@ const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { runsRouter } = require('./api/runs');
const { actionsRouter } = require('./api/actions');
const { actionsRouter } = require('./api/actions');
const { sendError, providerSecret } = require('./util');
const { sendError, providerSecret } = require('./util');
+const creations = require('./creations');
+const home = require('./home');
const openapi = require('./openapi');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
const { CAPS } = require('./auth');
const { CAPS } = require('./auth');
@@ -46,8 +48,23 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -46,8 +48,23 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.
// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.
// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.
// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.
const publicCorsPaths = new Set(['/stats', '/release.json']);
const publicCorsPaths = new Set(['/stats', '/release.json']);
+ // The creation catalogue is public data for every origin: an app or a page may read it without a
+ // token, so it answers `*` rather than the exact-origin list above.
+ const openCorsPaths = new Set(['/api/v1/creations', '/api/v1/creations.md']);
const corsOrigins = new Set((config.cors && config.cors.origins) || []);
const corsOrigins = new Set((config.cors && config.cors.origins) || []);
app.use((req, res, next) => {
app.use((req, res, next) => {
+ if (openCorsPaths.has(req.path)) {
+ if (req.method !== 'GET' && req.method !== 'OPTIONS') return next();
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.vary('Origin');
+ if (req.method === 'OPTIONS') {
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Accept');
+ res.setHeader('Access-Control-Max-Age', '600');
+ return res.sendStatus(204);
+ }
+ return next();
+ }
if (!publicCorsPaths.has(req.path) || (req.method !== 'GET' && req.method !== 'OPTIONS')) return next();
if (!publicCorsPaths.has(req.path) || (req.method !== 'GET' && req.method !== 'OPTIONS')) return next();
const origin = req.get('Origin');
const origin = req.get('Origin');
const allowed = Boolean(origin) && corsOrigins.has(origin);
const allowed = Boolean(origin) && corsOrigins.has(origin);
@@ -92,6 +109,31 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -92,6 +109,31 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
});
});
+ // What AI can make (ai.creation-list@1): every creation that is live now, built from the registered
+ // workflows and tasks. Public and cached for five minutes; the Markdown twin is the same list for AI
+ // readers and a page that wants plain text. A workflow that is not active leaves the list.
+ async function creationList() {
+ const [workflows, tasks] = await Promise.all([registry.listWorkflows(), choices ? choices.tasks() : []]);
+ return creations.catalogue({ workflows, tasks });
+ }
+ const sendCreations = (markdown) => async (req, res, next) => {
+ try {
+ const list = await creationList();
+ const generatedAt = new Date(clock.now()).toISOString();
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ if (markdown || req.query.format === 'markdown') return res.set('Content-Type', 'text/markdown; charset=utf-8').send(creations.renderMarkdown(list, { generatedAt }));
+ return res.json({ creations: list, count: list.length, generated_at: generatedAt });
+ } catch (err) { next(err); }
+ };
+ app.get('/api/v1/creations', sendCreations(false));
+ app.get('/api/v1/creations.md', sendCreations(true));
+ app.get('/llms.txt', async (req, res, next) => {
+ try {
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ res.type('text/plain; charset=utf-8').send(home.llmsTxt({ siteUrl: config.baseUrl, creations: await creationList() }));
+ } catch (err) { next(err); }
+ });
+
// Readiness (openvibe-shared/ready): 503 when the database, the workflows or the Network key
// Readiness (openvibe-shared/ready): 503 when the database, the workflows or the Network key
// fail; provider configuration is optional and degrades it (see observability.js).
// fail; provider configuration is optional and degrades it (see observability.js).
const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });
const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });
@@ -276,6 +318,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -276,6 +318,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry',
'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
+ 'GET /api/v1/creations what AI can make (public, cached, CORS open); .md for Markdown',
+ 'GET /llms.txt the same catalogue for AI readers',
'GET /api/health, /api/ready, /release.json',
'GET /api/health, /api/ready, /release.json',
'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
`GET ${EXCHANGE}/privacy/keys your own provider keys and session cookies`,
`GET ${EXCHANGE}/privacy/keys your own provider keys and session cookies`,
@@ -296,12 +340,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -296,12 +340,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
app.get('/index.md', moved('/index.md'));
app.get('/index.md', moved('/index.md'));
app.get('/stats.md', moved('/models.md'));
app.get('/stats.md', moved('/models.md'));
app.get('/updates.md', moved('/status.md'));
app.get('/updates.md', moved('/status.md'));
- for (const p of ['/terms', '/privacy', '/dmca', '/sitemap.xml', '/llms.txt', '/llms-full.txt']) app.get(p, moved(p));
+ for (const p of ['/terms', '/privacy', '/dmca', '/sitemap.xml', '/llms-full.txt']) app.get(p, moved(p));
// Discovery: the human site is Exchange, so this origin asks crawlers to leave it alone and names the map
// Discovery: the human site is Exchange, so this origin asks crawlers to leave it alone and names the map
// that matters. Machine routes (the API, release.json, a run's receipt) are not crawled anyway.
// that matters. Machine routes (the API, release.json, a run's receipt) are not crawled anyway.
app.get('/robots.txt', (_req, res) => {
app.get('/robots.txt', (_req, res) => {
- res.type('text/plain').send(`User-agent: *\nDisallow: /\nDisallow: /runs\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\n# The human site is OpenVibe.Exchange; the API stays here.\nSitemap: ${EXCHANGE}/sitemap.xml\n`);
+ res.type('text/plain').send(`User-agent: *\nAllow: /llms.txt\nAllow: /api/v1/creations\nAllow: /api/v1/creations.md\nDisallow: /\nDisallow: /runs\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\n# The human site is OpenVibe.Exchange; the API stays here.\nSitemap: ${EXCHANGE}/sitemap.xml\n`);
});
});
// A browser asking for a page that is not here goes to the Exchange home: the human site moved, and a
// A browser asking for a page that is not here goes to the Exchange home: the human site moved, and a
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+'use strict';
+/**
+ * The catalogue of things AI can make (owner, 11-09, item 45): one entry per creation, built from the
+ * registered workflows and tasks rather than a hand-kept list.
+ *
+ * catalogue({ workflows, tasks }) the live entries, for GET /api/v1/creations
+ * renderMarkdown(creations) the same list as Markdown, for GET /api/v1/creations.md
+ * problems({ workflows, tasks }) [] when the catalogue and the registry agree, else every drift
+ *
+ * An entry lives on the workflow that does the work: its definition carries `metadata.creation`, so
+ * registering the workflow registers its catalogue entry. A creation that is a task rather than a
+ * workflow (Design's icon drawing) is declared in TASK_CREATIONS and must name a registered task.
+ *
+ * The check is deliberately two-way:
+ * - every workflow in a product namespace has a catalogue entry or `metadata.internal: true`, so a
+ * new person-facing workflow cannot be registered without saying what it makes (or that it is not
+ * a creation);
+ * - every entry names a workflow or task that exists, and an entry on a workflow names that workflow.
+ *
+ * Only live things are listed: a workflow whose registry status is not `active`, or a task that is gone
+ * or whose workflow is not active, is left out. The platform namespaces (live, network, ai, chat,
+ * research) are not product surfaces and need no entry.
+ */
+
+// The product namespaces: a workflow here is something a person uses, so it declares what it makes.
+const CATALOGUE_NAMESPACES = new Set(['blog', 'codes', 'coupons', 'deals', 'food', 'games', 'media', 'meme', 'moderation', 'news', 'pics', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki']);
+
+const PRODUCTS = ['ai', 'blog', 'codes', 'community', 'design', 'food', 'games', 'live', 'media', 'meme', 'news', 'pics', 'quest', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki'];
+
+const PRODUCT_NAMES = {
+ ai: 'AI', blog: 'Blog', codes: 'Codes', community: 'Community', design: 'Design', food: 'Food', games: 'Games',
+ live: 'Live', media: 'Media', meme: 'Meme', news: 'News', pics: 'Pics', quest: 'Quest', rent: 'Rent',
+ reviews: 'Reviews', space: 'Space', tools: 'Tools', trade: 'Trade', wiki: 'Wiki',
+};
+
+const ID_RE = /^[a-z][a-z0-9-]{1,63}$/;
+const KEY_RE = /^[a-z][a-z0-9_-]*\.[a-z0-9_.-]{1,100}$/;
+const ENTRY_FIELDS = ['id', 'product', 'name', 'gives', 'returns', 'access', 'costs_coins', 'start_url'];
+
+// Creations that are a registered task rather than a workflow (rich-content plan §1). The task names the
+// workflow it runs, so the entry is listed only while both the task and that workflow are live.
+const TASK_CREATIONS = [
+ {
+ id: 'icon',
+ product: 'design',
+ name: 'Draw an icon',
+ gives: 'A name for the icon and what it stands for.',
+ returns: 'Up to four line drawings in OpenVibe\'s hand, each with its solid twin, checked before you see them.',
+ task: 'design.icon',
+ workflow: 'ai.chat',
+ access: { guest: true, signed_in: true, floor_18: false },
+ costs_coins: true,
+ start_url: 'https://openvibe.design/icons',
+ },
+];
+
+/** One creation as the API answers it: the catalogue fields, with the workflow and task it names. */
+function entryOf(source, { workflow = null, task = null } = {}) {
+ return {
+ id: source.id,
+ product: source.product,
+ name: source.name,
+ gives: source.gives,
+ returns: source.returns,
+ workflow: source.workflow !== undefined ? source.workflow : workflow,
+ task: source.task !== undefined ? source.task : task,
+ access: { guest: Boolean(source.access && source.access.guest), signed_in: Boolean(source.access && source.access.signed_in), floor_18: Boolean(source.access && source.access.floor_18) },
+ costs_coins: Boolean(source.costs_coins),
+ start_url: source.start_url,
+ };
+}
+
+/** What is wrong with one entry, as plain sentences ([] when it is complete). */
+function entryProblems(entry, where) {
+ const bad = [];
+ if (!entry || typeof entry !== 'object') return [`${where}: not an object`];
+ for (const k of Object.keys(entry)) if (!ENTRY_FIELDS.includes(k) && !['workflow', 'task'].includes(k)) bad.push(`${where}: unknown field ${k}`);
+ if (!ID_RE.test(String(entry.id || ''))) bad.push(`${where}: id must be lowercase letters, digits and dashes`);
+ if (!PRODUCTS.includes(entry.product)) bad.push(`${where}: unknown product ${entry.product}`);
+ if (typeof entry.name !== 'string' || entry.name.length < 3 || entry.name.length > 80) bad.push(`${where}: name must be 3-80 characters`);
+ if (typeof entry.gives !== 'string' || entry.gives.length < 3 || entry.gives.length > 300) bad.push(`${where}: gives must be 3-300 characters`);
+ if (typeof entry.returns !== 'string' || entry.returns.length < 3 || entry.returns.length > 300) bad.push(`${where}: returns must be 3-300 characters`);
+ if (typeof entry.costs_coins !== 'boolean') bad.push(`${where}: costs_coins must be a boolean`);
+ if (typeof entry.start_url !== 'string' || !/^https:\/\/[^\s]+$/.test(entry.start_url)) bad.push(`${where}: start_url must be an https URL`);
+ const a = entry.access;
+ if (!a || typeof a.guest !== 'boolean' || typeof a.signed_in !== 'boolean' || typeof a.floor_18 !== 'boolean') bad.push(`${where}: access needs guest, signed_in and floor_18 booleans`);
+ else if (!a.guest && !a.signed_in) bad.push(`${where}: nobody may use it (access.guest and access.signed_in are both false)`);
+ if (entry.workflow != null && !KEY_RE.test(String(entry.workflow))) bad.push(`${where}: workflow is not a workflow key`);
+ if (entry.task != null && !KEY_RE.test(String(entry.task))) bad.push(`${where}: task is not a task key`);
+ if (entry.workflow == null && entry.task == null) bad.push(`${where}: names neither a workflow nor a task`);
+ return bad;
+}
+
+function sortKey(entry) {
+ return `${String(entry.product)} ${String(entry.name)} ${String(entry.id)}`;
+}
+
+/**
+ * The live catalogue. `workflows` are registry records (key, status, metadata); `tasks` are the registered
+ * tasks (name, workflow). Nothing is listed unless the workflow doing the work is active.
+ */
+function catalogue({ workflows = [], tasks = [] } = {}) {
+ const live = new Map(workflows.filter((w) => w && w.status === 'active').map((w) => [w.key, w]));
+ const taskByName = new Map(tasks.filter((t) => t && t.name).map((t) => [t.name, t]));
+ const seen = new Set();
+ const out = [];
+
+ for (const w of live.values()) {
+ const c = w.metadata && w.metadata.creation;
+ if (!c) continue;
+ if (c.workflow !== w.key) continue; // a typo: the check names it, the API leaves it out
+ const entry = entryOf(c, { workflow: w.key });
+ if (entryProblems(entry, w.key).length || seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ out.push(entry);
+ }
+
+ for (const c of TASK_CREATIONS) {
+ const t = taskByName.get(c.task);
+ if (!t) continue;
+ if (c.workflow && !live.has(c.workflow)) continue;
+ const entry = entryOf(c);
+ if (entryProblems(entry, c.id).length || seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ out.push(entry);
+ }
+
+ return out.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
+}
+
+/** Every way the catalogue and the registry disagree, as plain sentences ([] when they agree). */
+function problems({ workflows = [], tasks = [] } = {}) {
+ const bad = [];
+ const workflowKeys = new Set(workflows.filter((w) => w && w.key).map((w) => w.key));
+ const taskNames = new Set(tasks.filter((t) => t && t.name).map((t) => t.name));
+ const ids = new Map();
+
+ for (const w of workflows) {
+ if (!w || !w.key) continue;
+ const c = w.metadata && w.metadata.creation;
+ if (!c) {
+ if (CATALOGUE_NAMESPACES.has(String(w.key).split('.')[0]) && !(w.metadata && w.metadata.internal)) {
+ bad.push(`${w.key}: a product workflow needs metadata.creation (what a person can make) or metadata.internal: true`);
+ }
+ continue;
+ }
+ const entry = entryOf(c);
+ const where = `${w.key} (metadata.creation)`;
+ bad.push(...entryProblems(entry, where));
+ if (c.workflow !== w.key) bad.push(`${where}: workflow must be "${w.key}" (it names ${JSON.stringify(c.workflow)})`);
+ if (c.task != null && !taskNames.has(c.task)) bad.push(`${where}: task ${c.task} is not registered`);
+ if (ids.has(entry.id)) bad.push(`${where}: id ${entry.id} is also used by ${ids.get(entry.id)}`);
+ else ids.set(entry.id, w.key);
+ }
+
+ for (const c of TASK_CREATIONS) {
+ const entry = entryOf(c);
+ const where = `${c.id} (TASK_CREATIONS)`;
+ bad.push(...entryProblems(entry, where));
+ if (!taskNames.has(c.task)) bad.push(`${where}: task ${c.task} is not registered`);
+ if (c.workflow != null && !workflowKeys.has(c.workflow)) bad.push(`${where}: workflow ${c.workflow} does not exist`);
+ if (ids.has(entry.id)) bad.push(`${where}: id ${entry.id} is also used by ${ids.get(entry.id)}`);
+ else ids.set(entry.id, 'TASK_CREATIONS');
+ }
+
+ return bad;
+}
+
+const ACCESS = (a) => (a.guest && a.signed_in ? 'Anyone, signed in or not' : a.guest ? 'Anyone' : 'Signed in');
+
+/** The same catalogue as Markdown, grouped by product: for GET /api/v1/creations.md and /llms.txt. */
+function renderMarkdown(creations, { generatedAt = null, heading = true } = {}) {
+ const byProduct = new Map();
+ for (const c of creations) {
+ if (!byProduct.has(c.product)) byProduct.set(c.product, []);
+ byProduct.get(c.product).push(c);
+ }
+ const lines = [];
+ if (heading) {
+ lines.push(
+ '# What AI can make on OpenVibe',
+ '',
+ `${creations.length} creation${creations.length === 1 ? '' : 's'}, built from the workflows and tasks that are live now${generatedAt ? ` (as of ${generatedAt})` : ''}.`,
+ '',
+ 'The same list as data: GET https://ai.openvibe.services/api/v1/creations (public, cached, CORS open) and its contract `ai.creation-list@1`.',
+ '',
+ );
+ }
+ const productName = (id) => PRODUCT_NAMES[id] || id;
+ for (const [product, list] of [...byProduct.entries()].sort((a, b) => productName(a[0]).localeCompare(productName(b[0])))) {
+ lines.push(`## ${PRODUCT_NAMES[product] || product}`, '');
+ for (const c of list.sort((a, b) => a.name.localeCompare(b.name))) {
+ lines.push(`### ${c.name}`, '');
+ lines.push(`- You give it: ${c.gives}`);
+ lines.push(`- You get back: ${c.returns}`);
+ lines.push(`- Who: ${ACCESS(c.access)}${c.access.floor_18 ? ', 18+ only' : ''}`);
+ lines.push(`- Cost: ${c.costs_coins ? 'a run may use paid AI after your free allowance' : 'free'}`);
+ lines.push(`- Start here: ${c.start_url}`);
+ lines.push(`- Behind it: ${c.workflow ? `workflow \`${c.workflow}\`` : ''}${c.workflow && c.task ? ' · ' : ''}${c.task ? `task \`${c.task}\`` : ''}`);
+ lines.push('');
+ }
+ }
+ return lines.join('\n');
+}
+
+module.exports = { catalogue, problems, renderMarkdown, CATALOGUE_NAMESPACES, PRODUCTS, PRODUCT_NAMES, TASK_CREATIONS };
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
*
*
* renderHome({ siteUrl, stats }) the whole document (siteUrl: the public origin, config.baseUrl; stats: { days, rows })
* renderHome({ siteUrl, stats }) the whole document (siteUrl: the public origin, config.baseUrl; stats: { days, rows })
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
- * llmsTxt({ siteUrl }) /llms.txt, the same facts as plain text
+ * llmsTxt({ siteUrl, creations }) /llms.txt, the same facts as plain text, with the live catalogue
*/
*/
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -16,6 +16,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -16,6 +16,7 @@ const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const { renderMarkdown } = require('./creations');
const SITE_NAME = 'OpenVibe.AI';
const SITE_NAME = 'OpenVibe.AI';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -411,7 +412,7 @@ function llmsFullTxt({ siteUrl }) {
@@ -411,7 +412,7 @@ function llmsFullTxt({ siteUrl }) {
// llmstxt.org: the pages as links, each of this site's own at its Markdown twin (AGT-5, openvibe-shared 4.108.0's markdown
// llmstxt.org: the pages as links, each of this site's own at its Markdown twin (AGT-5, openvibe-shared 4.108.0's markdown
// mode); the developer-app facts as plain lines; ## Optional last.
// mode); the developer-app facts as plain lines; ## Optional last.
-function llmsTxt({ siteUrl }) {
+function llmsTxt({ siteUrl, creations = [] }) {
const head = seo.llmsTxt({
const head = seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: DESCRIPTION,
summary: DESCRIPTION,
@@ -419,8 +420,9 @@ function llmsTxt({ siteUrl }) {
@@ -419,8 +420,9 @@ function llmsTxt({ siteUrl }) {
base: siteUrl,
base: siteUrl,
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
- { title: 'Home', url: `${siteUrl}/` },
- { title: 'Prices and measured latency per provider and model', url: `${siteUrl}/stats` },
+ { title: 'What AI can make (the same catalogue as data; public, cached, CORS open)', url: `${siteUrl}/api/v1/creations` },
+ { title: 'What AI can make, as Markdown', url: `${siteUrl}/api/v1/creations.md` },
+ { title: 'The human site (home, prices, status, console)', url: 'https://openvibe.exchange/' },
{ title: 'Developer guide (tokens, limits, billing, sandbox)', url: GUIDE },
{ title: 'Developer guide (tokens, limits, billing, sandbox)', url: GUIDE },
{ title: 'API reference (OpenAPI, generated from the contracts)', url: API_DOCS_URL },
{ title: 'API reference (OpenAPI, generated from the contracts)', url: API_DOCS_URL },
{ title: 'Create a project and an app', url: PROJECTS_URL },
{ title: 'Create a project and an app', url: PROJECTS_URL },
@@ -430,10 +432,7 @@ function llmsTxt({ siteUrl }) {
@@ -430,10 +432,7 @@ function llmsTxt({ siteUrl }) {
const optional = seo.llmsTxt({ name: '', sections: [], optional: [
const optional = seo.llmsTxt({ name: '', sections: [], optional: [
{ title: 'Owner AI operator console', url: 'https://openvibe.exchange/console' },
{ title: 'Owner AI operator console', url: 'https://openvibe.exchange/console' },
{ title: 'Your keys and accounts', url: 'https://openvibe.exchange/privacy/keys' },
{ title: 'Your keys and accounts', url: 'https://openvibe.exchange/privacy/keys' },
- { title: 'What shipped on OpenVibe.AI', url: `${siteUrl}/updates` },
{ title: 'Source (AGPL-3.0)', url: SOURCE },
{ title: 'Source (AGPL-3.0)', url: SOURCE },
- { title: 'Sitemap', url: `${siteUrl}/sitemap.xml` },
- { title: 'Full text for language models', url: `${siteUrl}/llms-full.txt` },
], markdown: true, base: siteUrl }).split('\n## Optional\n')[1];
], markdown: true, base: siteUrl }).split('\n## Optional\n')[1];
return [
return [
head.replace(/\n+$/, ''),
head.replace(/\n+$/, ''),
@@ -447,6 +446,12 @@ function llmsTxt({ siteUrl }) {
@@ -447,6 +446,12 @@ function llmsTxt({ siteUrl }) {
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'',
'',
+ '## What AI can make',
+ '',
+ `The live catalogue, built from the registered workflows and tasks: ${creations.length} creation${creations.length === 1 ? '' : 's'} today. Each line names what a person gives it, what comes back, who may use it, whether it can cost coins and where to start.`,
+ '',
+ renderMarkdown(creations, { heading: false }),
+ '',
`## Optional\n${optional}`,
`## Optional\n${optional}`,
].join('\n');
].join('\n');
}
}
@@ -336,7 +336,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -336,7 +336,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, refusal, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, refusal, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -89,6 +89,37 @@ function route(method, p, operation) {
@@ -89,6 +89,37 @@ function route(method, p, operation) {
(PATHS[p] = PATHS[p] || {})[method] = operation;
(PATHS[p] = PATHS[p] || {})[method] = operation;
}
}
+// ── What AI can make (owner, 11-09, item 45) ────────────────────────────────────────────────
+const creation = {
+ type: 'object', additionalProperties: false,
+ required: ['id', 'product', 'name', 'gives', 'returns', 'workflow', 'task', 'access', 'costs_coins', 'start_url'],
+ properties: {
+ id: str({ pattern: '^[a-z][a-z0-9-]{1,63}$' }),
+ product: str({ enum: ['ai', 'blog', 'codes', 'community', 'design', 'food', 'games', 'live', 'media', 'meme', 'news', 'pics', 'quest', 'rent', 'reviews', 'space', 'tools', 'trade', 'wiki'] }),
+ name: str({ maxLength: 80 }), gives: str({ maxLength: 300 }), returns: str({ maxLength: 300 }),
+ workflow: nullable('string'), task: nullable('string'),
+ access: {
+ type: 'object', additionalProperties: false, required: ['guest', 'signed_in', 'floor_18'],
+ properties: { guest: { type: 'boolean' }, signed_in: { type: 'boolean' }, floor_18: { type: 'boolean' } },
+ },
+ costs_coins: { type: 'boolean' }, start_url: str({ format: 'uri' }),
+ },
+};
+const creationList = {
+ type: 'object', additionalProperties: false, required: ['creations', 'count', 'generated_at'],
+ properties: { creations: arrayOf(creation), count: integer({ minimum: 0 }), generated_at: str({ format: 'date-time' }) },
+};
+route('get', '/creations', {
+ operationId: 'listCreations', summary: 'What AI can make: every creation that is live now', tags: ['creations'], security: [],
+ description: 'Public, cached for five minutes and open to every origin (Access-Control-Allow-Origin: *). One entry per thing AI can make, built from the registered workflows and tasks (ai.creation-list@1): the product, a plain name, what the person gives it and what comes back, the workflow or task behind it, who may use it, whether it can cost coins and where to start. Only a workflow whose status is active is listed. The same list as Markdown is GET /api/v1/creations.md, or this path with ?format=markdown, and it is in /llms.txt.',
+ responses: { 200: json('The live catalogue', creationList), ...errors([429]) },
+});
+route('get', '/creations.md', {
+ operationId: 'listCreationsMarkdown', summary: 'What AI can make, as Markdown', tags: ['creations'], security: [],
+ description: 'The same live catalogue grouped by product, for AI readers and pages that want plain text (ai.creation-list@1).',
+ responses: { 200: { description: 'The catalogue as Markdown', content: { 'text/markdown': { schema: { type: 'string' } } } }, ...errors([429]) },
+});
+
// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────
// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────
route('get', '/tasks', {
route('get', '/tasks', {
operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],
operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],
@@ -59,6 +59,7 @@ const workflow = {
@@ -59,6 +59,7 @@ const workflow = {
output_schema: template.output_schema,
output_schema: template.output_schema,
default_route: 'default.json',
default_route: 'default.json',
steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],
steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],
+ metadata: { internal: true },
};
};
module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };
module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };
@@ -154,6 +154,15 @@ const workflows = [
@@ -154,6 +154,15 @@ const workflows = [
output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),
output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),
// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.
// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.
steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',
steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',
+ metadata: {
+ creation: {
+ id: 'image', product: 'pics', name: 'Make an image', workflow: 'ai.image',
+ gives: 'A description of the image, its size and how many you want.',
+ returns: 'One to four images.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.pics/',
+ },
+ },
},
},
];
];
@@ -23,7 +23,16 @@ const workflows = [{ key: 'content.devblog', name: 'Draft a devblog from merged
@@ -23,7 +23,16 @@ const workflows = [{ key: 'content.devblog', name: 'Draft a devblog from merged
input_schema: { ...obj({ site: str(80), from: str(10), to: str(10), sources: SOURCES(1, 200), stories: arr({ type: 'object' }, 1, 8), screenshots: arr(obj({ media_id: str(80), alt: str(300), caption: str(500), source: { type: 'integer', minimum: 0 } }), 0, 24), voice: str(6000) }) },
input_schema: { ...obj({ site: str(80), from: str(10), to: str(10), sources: SOURCES(1, 200), stories: arr({ type: 'object' }, 1, 8), screenshots: arr(obj({ media_id: str(80), alt: str(300), caption: str(500), source: { type: 'integer', minimum: 0 } }), 0, 24), voice: str(6000) }) },
output_schema: obj({ title: str(80), summary: str(2000), names: arr(str(80), 5, 5), blocks: { type: 'object' }, citations: arr({ type: 'integer', minimum: 0 }, 1, 200), checks: arr(str(500), 0, 30), steps: arr(str(100), 1, 10) }),
output_schema: obj({ title: str(80), summary: str(2000), names: arr(str(80), 5, 5), blocks: { type: 'object' }, citations: arr({ type: 'integer', minimum: 0 }, 1, 200), checks: arr(str(500), 0, 30), steps: arr(str(100), 1, 10) }),
steps: [{ kind: 'devblog', cite: true }], default_route: 'background.json', cache_mode: 'none',
steps: [{ kind: 'devblog', cite: true }], default_route: 'background.json', cache_mode: 'none',
- metadata: { seed: true, draft: true, task: 'blog.devblog.write', tools: ['changelog.stories', 'site.screenshot', 'facts.check'], max_model_calls: 5 } }];
+ metadata: {
+ seed: true, draft: true, task: 'blog.devblog.write', tools: ['changelog.stories', 'site.screenshot', 'facts.check'], max_model_calls: 5,
+ creation: {
+ id: 'devblog', product: 'blog', name: 'Write my project\'s devblog', workflow: 'content.devblog', task: 'blog.devblog.write',
+ gives: 'The merged changes for one site over a period, and the stories to tell.',
+ returns: 'A named devblog with sections, hero art and the full patch notes folded in.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.blog/write',
+ },
+ } }];
const SLOP = /\b(unleashed|supercharged|level up|game changer|next level)\b/i;
const SLOP = /\b(unleashed|supercharged|level up|game changer|next level)\b/i;
function issuesOf(draft, input) {
function issuesOf(draft, input) {
const issues = [];
const issues = [];
@@ -78,6 +78,15 @@ const workflows = [
@@ -78,6 +78,15 @@ const workflows = [
// Not cached: a result made while the local model was down (or before a budget) must not outlive that, and
// Not cached: a result made while the local model was down (or before a budget) must not outlive that, and
// callers keep what they asked for (Live's vod_ai_state/clip_ai_state).
// callers keep what they asked for (Live's vod_ai_state/clip_ai_state).
cache_mode: 'none',
cache_mode: 'none',
+ metadata: {
+ creation: {
+ id: 'recording-analysis', product: 'media', name: 'Analyse a recording', workflow: 'media.analyze',
+ gives: 'A video, clip or audio file.',
+ returns: 'Its scenes, speech, highlights and a short overview, measured on our own machines first.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.media/',
+ },
+ },
},
},
];
];
@@ -15,6 +15,15 @@ const workflow = {
@@ -15,6 +15,15 @@ const workflow = {
description: 'Generate a handful of original, short caption ideas for a selected meme template.',
description: 'Generate a handful of original, short caption ideas for a selected meme template.',
input_schema: template.input_schema, output_schema: template.output_schema, default_route: 'meme.caption',
input_schema: template.input_schema, output_schema: template.output_schema, default_route: 'meme.caption',
steps: [{ kind: 'llm', template: 'meme.caption', operation: 'generate', output: 'json', params: { max_tokens: 700, temperature: 0.85 } }],
steps: [{ kind: 'llm', template: 'meme.caption', operation: 'generate', output: 'json', params: { max_tokens: 700, temperature: 0.85 } }],
+ metadata: {
+ creation: {
+ id: 'meme-caption', product: 'meme', name: 'Write meme caption ideas', workflow: 'meme.caption',
+ gives: 'A meme format and the situation you want to joke about.',
+ returns: 'Three to five original caption ideas for that format.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.meme/',
+ },
+ },
};
};
module.exports = { templates: [template], workflows: [workflow] };
module.exports = { templates: [template], workflows: [workflow] };
@@ -83,6 +83,15 @@ const workflows = [
@@ -83,6 +83,15 @@ const workflows = [
}),
}),
default_route: 'wiki.write',
default_route: 'wiki.write',
steps: [{ kind: 'llm', template: 'wiki.generate_space', operation: 'generate', output: 'json', prepare: 'wiki.space', cite: true, params: { max_tokens: 4000, temperature: 0.3, timeout_ms: 150000 } }],
steps: [{ kind: 'llm', template: 'wiki.generate_space', operation: 'generate', output: 'json', prepare: 'wiki.space', cite: true, params: { max_tokens: 4000, temperature: 0.3, timeout_ms: 150000 } }],
+ metadata: {
+ creation: {
+ id: 'wiki', product: 'wiki', name: 'Make a wiki', workflow: 'wiki.generate_space',
+ gives: 'A topic, the sources to build it from, and who it is for.',
+ returns: 'A wiki plan: its title, a summary and the pages to make, each with an outline and its citations.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.wiki/spaces',
+ },
+ },
},
},
{
{
key: 'wiki.generate_page', name: 'Write a wiki page', namespace: 'wiki',
key: 'wiki.generate_page', name: 'Write a wiki page', namespace: 'wiki',
@@ -99,6 +108,15 @@ const workflows = [
@@ -99,6 +108,15 @@ const workflows = [
}),
}),
default_route: 'wiki.write',
default_route: 'wiki.write',
steps: [{ kind: 'llm', template: 'wiki.generate_page', operation: 'generate', output: 'json', prepare: 'wiki.page', cite: true, params: { max_tokens: 7000, temperature: 0.4, timeout_ms: 170000 } }],
steps: [{ kind: 'llm', template: 'wiki.generate_page', operation: 'generate', output: 'json', prepare: 'wiki.page', cite: true, params: { max_tokens: 7000, temperature: 0.4, timeout_ms: 170000 } }],
+ metadata: {
+ creation: {
+ id: 'wiki-article', product: 'wiki', name: 'Write a wiki article', workflow: 'wiki.generate_page',
+ gives: 'A page title, the wiki it belongs to, and the sources to cite.',
+ returns: 'A cited article: a lead, sections, an infobox and the pictures that fit.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.wiki/spaces',
+ },
+ },
},
},
{
{
// The wiki writer (rich-content plan §5): research, then every sentence cited and the infobox from facts.
// The wiki writer (rich-content plan §5): research, then every sentence cited and the infobox from facts.
@@ -122,12 +140,30 @@ const workflows = [
@@ -122,12 +140,30 @@ const workflows = [
{ kind: 'research' },
{ kind: 'research' },
{ kind: 'llm', template: 'content.wiki', operation: 'generate', output: 'json', prepare: 'content.wiki', cite: true, params: { max_tokens: 8000, temperature: 0.4, timeout_ms: 180000 } },
{ kind: 'llm', template: 'content.wiki', operation: 'generate', output: 'json', prepare: 'content.wiki', cite: true, params: { max_tokens: 8000, temperature: 0.4, timeout_ms: 180000 } },
],
],
+ metadata: {
+ creation: {
+ id: 'wiki-research', product: 'wiki', name: 'Research and write a wiki article', workflow: 'content.wiki',
+ gives: 'A title and what the article should cover; it finds its own sources.',
+ returns: 'A cited article as sentences you can check, filed as a proposal for a person to review.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.wiki/spaces',
+ },
+ },
},
},
{
{
key: 'blog.draft_post', name: 'Draft a blog post', namespace: 'blog',
key: 'blog.draft_post', name: 'Draft a blog post', namespace: 'blog',
input_schema: OBJ({ topic: STR(300), brief: STR(4000), tone: STR(200), audience: STR(300), sources: SOURCES(0) }, ['topic']),
input_schema: OBJ({ topic: STR(300), brief: STR(4000), tone: STR(200), audience: STR(300), sources: SOURCES(0) }, ['topic']),
output_schema: OBJ({ title: STR(200), dek: STR(400), body_markdown: STR(40000), tags: ARR(STR(40), 10), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ title: STR(200), dek: STR(400), body_markdown: STR(40000), tags: ARR(STR(40), 10), citations: CITES, gaps: GAPS }),
default_route: 'background.json',
default_route: 'background.json',
+ metadata: {
+ creation: {
+ id: 'blog-post', product: 'blog', name: 'Write a blog post', workflow: 'blog.draft_post',
+ gives: 'A topic, and optionally a brief, a tone and who it is for.',
+ returns: 'A Markdown draft with a title, a standfirst and cited sources, saved as a draft you can edit.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.blog/write',
+ },
+ },
},
},
{
{
// OpenVibe.Blog's AI desks (server/desks.js): a digest of what a desk's sources published, each section cited.
// OpenVibe.Blog's AI desks (server/desks.js): a digest of what a desk's sources published, each section cited.
@@ -135,6 +171,7 @@ const workflows = [
@@ -135,6 +171,7 @@ const workflows = [
input_schema: OBJ({ desk: STR(120), about: STR(400), period: STR(120), sources: SOURCES(2) }, ['desk', 'period', 'sources']),
input_schema: OBJ({ desk: STR(120), about: STR(400), period: STR(120), sources: SOURCES(2) }, ['desk', 'period', 'sources']),
output_schema: OBJ({ title: STR(200), dek: STR(400), sections: ARR(OBJ({ heading: STR(160), paragraphs: ARR(CLAIM, 8) }), 6), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ title: STR(200), dek: STR(400), sections: ARR(OBJ({ heading: STR(160), paragraphs: ARR(CLAIM, 8) }), 6), citations: CITES, gaps: GAPS }),
default_route: 'background.json',
default_route: 'background.json',
+ metadata: { internal: true },
},
},
{
{
key: 'news.summarize_story', name: 'Summarize a news story', namespace: 'news',
key: 'news.summarize_story', name: 'Summarize a news story', namespace: 'news',
@@ -143,18 +180,45 @@ const workflows = [
@@ -143,18 +180,45 @@ const workflows = [
input_schema: OBJ({ topic: STR(300), tone: { enum: ['brief', 'neutral', 'explained'] }, sources: SOURCES(1) }, ['sources']),
input_schema: OBJ({ topic: STR(300), tone: { enum: ['brief', 'neutral', 'explained'] }, sources: SOURCES(1) }, ['sources']),
output_schema: OBJ({ headline: STR(200), summary: STR(3000), key_points: ARR(CLAIM, 20), timeline: ARR(OBJ({ when: STR(60), what: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ headline: STR(200), summary: STR(3000), key_points: ARR(CLAIM, 20), timeline: ARR(OBJ({ when: STR(60), what: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),
default_route: 'background.json',
default_route: 'background.json',
+ metadata: {
+ creation: {
+ id: 'news-story', product: 'news', name: 'Summarize a news story', workflow: 'news.summarize_story',
+ gives: 'The source reports for one story, and whether you want it brief or explained.',
+ returns: 'A headline, a summary, the key points and a timeline, every claim cited.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.news/stories',
+ },
+ },
},
},
{
{
key: 'news.compare_perspectives', name: 'Compare perspectives on a story', namespace: 'news',
key: 'news.compare_perspectives', name: 'Compare perspectives on a story', namespace: 'news',
input_schema: OBJ({ topic: STR(300), sources: SOURCES(2) }, ['topic', 'sources']),
input_schema: OBJ({ topic: STR(300), sources: SOURCES(2) }, ['topic', 'sources']),
output_schema: OBJ({ question: STR(300), perspectives: ARR(OBJ({ label: STR(120), summary: STR(2000), citations: CITES }), 10), agreements: ARR(CLAIM, 10), disagreements: ARR(CLAIM, 10), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ question: STR(300), perspectives: ARR(OBJ({ label: STR(120), summary: STR(2000), citations: CITES }), 10), agreements: ARR(CLAIM, 10), disagreements: ARR(CLAIM, 10), citations: CITES, gaps: GAPS }),
default_route: 'news.summarize',
default_route: 'news.summarize',
+ metadata: {
+ creation: {
+ id: 'news-perspectives', product: 'news', name: 'Compare how sources tell a story', workflow: 'news.compare_perspectives',
+ gives: 'A question and the sources from each side of it.',
+ returns: 'Each perspective in its own terms, where they agree and where they differ.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.news/stories',
+ },
+ },
},
},
{
{
key: 'reviews.summarize_entity', name: 'Summarize review signals for an entity', namespace: 'reviews',
key: 'reviews.summarize_entity', name: 'Summarize review signals for an entity', namespace: 'reviews',
input_schema: OBJ({ entity: OBJ({ name: STR(200), type: STR(80) }, ['name']), sources: SOURCES(1) }, ['entity', 'sources']),
input_schema: OBJ({ entity: OBJ({ name: STR(200), type: STR(80) }, ['name']), sources: SOURCES(1) }, ['entity', 'sources']),
output_schema: OBJ({ summary: STR(2000), themes: ARR(STR(80), 12), pros: ARR(CLAIM, 12), cons: ARR(CLAIM, 12), coverage: { enum: ['weak', 'moderate', 'strong'] }, citations: CITES, gaps: GAPS }),
output_schema: OBJ({ summary: STR(2000), themes: ARR(STR(80), 12), pros: ARR(CLAIM, 12), cons: ARR(CLAIM, 12), coverage: { enum: ['weak', 'moderate', 'strong'] }, citations: CITES, gaps: GAPS }),
default_route: 'reviews.summarize',
default_route: 'reviews.summarize',
+ metadata: {
+ creation: {
+ id: 'review-summary', product: 'reviews', name: 'Summarize what reviewers say', workflow: 'reviews.summarize_entity',
+ gives: 'An entity and the review signals written about it.',
+ returns: 'The themes, pros and cons, each cited, and how strong the coverage is.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.reviews/',
+ },
+ },
},
},
{
{
key: 'deals.enrich_deal', name: 'Enrich a deal', namespace: 'deals',
key: 'deals.enrich_deal', name: 'Enrich a deal', namespace: 'deals',
@@ -170,6 +234,7 @@ const workflows = [
@@ -170,6 +234,7 @@ const workflows = [
summary: STR(1000), citations: CITES, gaps: GAPS,
summary: STR(1000), citations: CITES, gaps: GAPS,
}),
}),
default_route: 'deals.enrich',
default_route: 'deals.enrich',
+ metadata: { internal: true },
},
},
{
{
key: 'coupons.extract_coupon', name: 'Extract coupons', namespace: 'coupons',
key: 'coupons.extract_coupon', name: 'Extract coupons', namespace: 'coupons',
@@ -179,6 +244,7 @@ const workflows = [
@@ -179,6 +244,7 @@ const workflows = [
citations: CITES, gaps: GAPS,
citations: CITES, gaps: GAPS,
}),
}),
default_route: 'coupons.extract',
default_route: 'coupons.extract',
+ metadata: { internal: true },
},
},
{
{
key: 'trade.summarize_market_context', name: 'Summarize market context (informational)', namespace: 'trade',
key: 'trade.summarize_market_context', name: 'Summarize market context (informational)', namespace: 'trade',
@@ -191,18 +257,45 @@ const workflows = [
@@ -191,18 +257,45 @@ const workflows = [
citations: CITES, gaps: GAPS,
citations: CITES, gaps: GAPS,
}),
}),
default_route: 'background.json',
default_route: 'background.json',
+ metadata: {
+ creation: {
+ id: 'market-context', product: 'trade', name: 'Summarize a market\'s latest filings', workflow: 'trade.summarize_market_context',
+ gives: 'An instrument and the sourced market and filing context.',
+ returns: 'Sourced observations with their dates and a plain informational disclaimer; never advice.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.trade/',
+ },
+ },
},
},
{
{
key: 'codes.generate_docs', name: 'Generate documentation from code', namespace: 'codes',
key: 'codes.generate_docs', name: 'Generate documentation from code', namespace: 'codes',
input_schema: OBJ({ project: STR(200), audience: STR(200), sources: SOURCES(1) }, ['project', 'sources']),
input_schema: OBJ({ project: STR(200), audience: STR(200), sources: SOURCES(1) }, ['project', 'sources']),
output_schema: OBJ({ title: STR(200), overview: STR(4000), sections: ARR(OBJ({ heading: STR(200), body_markdown: STR(20000), citations: CITES }), 40), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ title: STR(200), overview: STR(4000), sections: ARR(OBJ({ heading: STR(200), body_markdown: STR(20000), citations: CITES }), 40), citations: CITES, gaps: GAPS }),
default_route: 'codes.generate_docs',
default_route: 'codes.generate_docs',
+ metadata: {
+ creation: {
+ id: 'project-docs', product: 'codes', name: 'Write docs for my project', workflow: 'codes.generate_docs',
+ gives: 'The project and its source files.',
+ returns: 'An overview and sections in Markdown, each citing the files it describes.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.codes/',
+ },
+ },
},
},
{
{
key: 'games.generate_lore', name: 'Generate game lore', namespace: 'games',
key: 'games.generate_lore', name: 'Generate game lore', namespace: 'games',
input_schema: OBJ({ world: STR(200), prompt: STR(4000), sources: SOURCES(0) }, ['world', 'prompt']),
input_schema: OBJ({ world: STR(200), prompt: STR(4000), sources: SOURCES(0) }, ['world', 'prompt']),
output_schema: OBJ({ title: STR(200), lore_markdown: STR(20000), entities: ARR(OBJ({ name: STR(120), kind: STR(60), description: STR(1000) }), 30), contradictions: ARR(STR(500), 10), citations: CITES, gaps: GAPS }),
output_schema: OBJ({ title: STR(200), lore_markdown: STR(20000), entities: ARR(OBJ({ name: STR(120), kind: STR(60), description: STR(1000) }), 30), contradictions: ARR(STR(500), 10), citations: CITES, gaps: GAPS }),
default_route: 'games.generate_lore',
default_route: 'games.generate_lore',
+ metadata: {
+ creation: {
+ id: 'game-lore', product: 'games', name: 'Write lore for my game world', workflow: 'games.generate_lore',
+ gives: 'Your world, its canon sources and what you want written.',
+ returns: 'Lore in Markdown with its entities, and every clash with canon named.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.games/',
+ },
+ },
},
},
{
{
key: 'tools.describe', name: 'Describe a tool', namespace: 'tools',
key: 'tools.describe', name: 'Describe a tool', namespace: 'tools',
@@ -215,6 +308,15 @@ const workflows = [
@@ -215,6 +308,15 @@ const workflows = [
citations: CITES, gaps: GAPS,
citations: CITES, gaps: GAPS,
}),
}),
default_route: 'tools.describe',
default_route: 'tools.describe',
+ metadata: {
+ creation: {
+ id: 'tool-page', product: 'tools', name: 'Describe a tool for its users', workflow: 'tools.describe',
+ gives: 'A tool\'s descriptor: what it is and what it takes.',
+ returns: 'A summary, how to use it, every input and output, its limits and an FAQ.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.tools/',
+ },
+ },
},
},
{
{
key: 'food.suggest_recipes', name: 'Suggest recipes from a pantry', namespace: 'food',
key: 'food.suggest_recipes', name: 'Suggest recipes from a pantry', namespace: 'food',
@@ -228,6 +330,15 @@ const workflows = [
@@ -228,6 +330,15 @@ const workflows = [
citations: CITES, gaps: GAPS,
citations: CITES, gaps: GAPS,
}),
}),
default_route: 'background.json',
default_route: 'background.json',
+ metadata: {
+ creation: {
+ id: 'recipes', product: 'food', name: 'Suggest recipes from my pantry', workflow: 'food.suggest_recipes',
+ gives: 'What is in your pantry, how many people you are cooking for, and what you feel like.',
+ returns: 'Up to three recipes with everyday amounts and short steps; any missing ingredient is named.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.food/',
+ },
+ },
},
},
{
{
key: 'rent.screen_listing', name: 'Screen a rental listing for scam signs', namespace: 'rent',
key: 'rent.screen_listing', name: 'Screen a rental listing for scam signs', namespace: 'rent',
@@ -243,6 +354,15 @@ const workflows = [
@@ -243,6 +354,15 @@ const workflows = [
citations: CITES,
citations: CITES,
}),
}),
default_route: 'rent.screen', cache_mode: 'private',
default_route: 'rent.screen', cache_mode: 'private',
+ metadata: {
+ creation: {
+ id: 'rental-check', product: 'rent', name: 'Check a rental listing for scam signs', workflow: 'rent.screen_listing',
+ gives: 'A rental listing as you found it.',
+ returns: 'Allow or review, the exact words behind each scam sign, and a plain reason.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.rent/',
+ },
+ },
},
},
{
{
key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',
key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',
@@ -256,12 +376,13 @@ const workflows = [
@@ -256,12 +376,13 @@ const workflows = [
citations: CITES,
citations: CITES,
}),
}),
default_route: 'moderation.classify', cache_mode: 'private',
default_route: 'moderation.classify', cache_mode: 'private',
+ metadata: { internal: true },
},
},
].map(w => ({
].map(w => ({
cache_mode: 'private',
cache_mode: 'private',
...w,
...w,
steps: w.steps || [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' || w.key === 'rent.screen_listing' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
steps: w.steps || [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' || w.key === 'rent.screen_listing' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
- metadata: { draft: true, seed: true },
+ metadata: { draft: true, seed: true, ...(w.metadata || {}) },
}));
}));
// Food's cupboard by photo or receipt (OpenVibe.Food /pantry/photo): a picture, not sources, so it has its own step.
// Food's cupboard by photo or receipt (OpenVibe.Food /pantry/photo): a picture, not sources, so it has its own step.
@@ -285,7 +406,7 @@ workflows.push({
@@ -285,7 +406,7 @@ workflows.push({
default_route: 'food.vision',
default_route: 'food.vision',
// A person's photo: never shared between callers (the stored run keeps only the image's hash, server/runs.js).
// A person's photo: never shared between callers (the stored run keeps only the image's hash, server/runs.js).
cache_mode: 'none',
cache_mode: 'none',
- metadata: { seed: true },
+ metadata: { seed: true, internal: true },
});
});
// OpenVibe.Pics: alt text, a caption and tags for one picture (accessibility, embeds and finding your own pictures).
// OpenVibe.Pics: alt text, a caption and tags for one picture (accessibility, embeds and finding your own pictures).
@@ -305,7 +426,16 @@ workflows.push({
@@ -305,7 +426,16 @@ workflows.push({
default_route: 'background.json',
default_route: 'background.json',
// A person's picture: never shared between callers (the stored run keeps only the image's hash).
// A person's picture: never shared between callers (the stored run keeps only the image's hash).
cache_mode: 'none',
cache_mode: 'none',
- metadata: { seed: true },
+ metadata: {
+ seed: true,
+ creation: {
+ id: 'picture-alt', product: 'pics', name: 'Describe a picture', workflow: 'pics.describe_image',
+ gives: 'A picture, and optionally the title you gave it.',
+ returns: 'Alt text, a caption and up to six tags, ready to paste.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.pics/',
+ },
+ },
});
});
// OpenVibe.Blog's satire desk ("Is This Real?"): invented on purpose, so it is not a sourced evidence package and has its
// OpenVibe.Blog's satire desk ("Is This Real?"): invented on purpose, so it is not a sourced evidence package and has its
@@ -325,7 +455,7 @@ workflows.push({
@@ -325,7 +455,7 @@ workflows.push({
steps: [{ kind: 'llm', template: 'blog.write_satire', operation: 'generate', output: 'json', params: { max_tokens: 2400, temperature: 0.9 } }],
steps: [{ kind: 'llm', template: 'blog.write_satire', operation: 'generate', output: 'json', params: { max_tokens: 2400, temperature: 0.9 } }],
default_route: 'background.json',
default_route: 'background.json',
cache_mode: 'private',
cache_mode: 'private',
- metadata: { seed: true },
+ metadata: { seed: true, internal: true },
});
});
module.exports = { templates, workflows };
module.exports = { templates, workflows };
@@ -106,7 +106,17 @@ const workflows = [
@@ -106,7 +106,17 @@ const workflows = [
input_schema: OBJ({ prompt: STR(4000), name: STR(40), current: ARR(FILE, MAX_FILES), components: STR(20000) }, ['prompt']),
input_schema: OBJ({ prompt: STR(4000), name: STR(40), current: ARR(FILE, MAX_FILES), components: STR(20000) }, ['prompt']),
output_schema: OBJ({ name: STR(40), description: STR(400), instructions: STR(4000), capabilities: ARR({ type: 'string', enum: CAPABILITIES }, 4), files: ARR(FILE, MAX_FILES) }),
output_schema: OBJ({ name: STR(40), description: STR(400), instructions: STR(4000), capabilities: ARR({ type: 'string', enum: CAPABILITIES }, 4), files: ARR(FILE, MAX_FILES) }),
steps: [{ kind: 'llm', template: 'space.app.build', operation: 'generate', output: 'json', prepare: 'space.app.build', postprocess: 'space.app.build', params: { max_tokens: 16000, timeout_ms: 180000 } }],
steps: [{ kind: 'llm', template: 'space.app.build', operation: 'generate', output: 'json', prepare: 'space.app.build', postprocess: 'space.app.build', params: { max_tokens: 16000, timeout_ms: 180000 } }],
- default_route: 'space.build', cache_mode: 'none', metadata: { seed: true },
+ default_route: 'space.build', cache_mode: 'none',
+ metadata: {
+ seed: true,
+ creation: {
+ id: 'space-app', product: 'space', name: 'Make a website, game or app', workflow: 'space.app.build',
+ gives: 'What you want it to do, and the files when you are changing one.',
+ returns: 'A working space: its name, what it does, the AI it may use and every file.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.space/projects',
+ },
+ },
},
},
{
{
key: 'space.app.ask', name: 'Space: a space\'s AI answers a visitor', namespace: 'space',
key: 'space.app.ask', name: 'Space: a space\'s AI answers a visitor', namespace: 'space',
@@ -114,7 +124,7 @@ const workflows = [
@@ -114,7 +124,7 @@ const workflows = [
input_schema: OBJ({ space: STR(40), instructions: STR(4000), transcript: STR(16000), message: STR(4000) }, ['space', 'message']),
input_schema: OBJ({ space: STR(40), instructions: STR(4000), transcript: STR(16000), message: STR(4000) }, ['space', 'message']),
output_schema: OBJ({ text: { type: 'string' } }),
output_schema: OBJ({ text: { type: 'string' } }),
steps: [{ kind: 'llm', template: 'space.app.ask', operation: 'chat', output: 'text', params: { max_tokens: 800, temperature: 0.7, timeout_ms: 25000 } }],
steps: [{ kind: 'llm', template: 'space.app.ask', operation: 'chat', output: 'text', params: { max_tokens: 800, temperature: 0.7, timeout_ms: 25000 } }],
- default_route: 'space.ask', cache_mode: 'none', metadata: { seed: true },
+ default_route: 'space.ask', cache_mode: 'none', metadata: { seed: true, internal: true },
},
},
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// What AI can make (ai.creation-list@1): the catalogue is built from the registered workflows and tasks,
+// is public, cached and open to every origin, has a Markdown twin, and the check fails when the code and
+// the registry disagree — a product workflow without an entry, or an entry naming something absent.
+const assert = require('assert');
+const { boot, request, suite } = require('./helpers');
+const creations = require('../server/creations');
+
+const t = suite('creations');
+let h;
+
+t.test('boot', async () => { h = await boot(); });
+
+t.test('the list is live, public, cached and open to every origin', async () => {
+ const r = await request(h.base, 'GET', '/api/v1/creations', { headers: { Origin: 'https://openvibe.exchange' } });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /application\/json/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ assert.ok(Array.isArray(r.body.creations) && r.body.creations.length >= 10, `entries: ${r.body.creations && r.body.creations.length}`);
+ assert.strictEqual(r.body.count, r.body.creations.length);
+ assert.ok(Date.parse(r.body.generated_at) > 0);
+ const ids = new Set(r.body.creations.map((c) => c.id));
+ for (const id of ['blog-post', 'devblog', 'wiki', 'wiki-article', 'space-app', 'image', 'icon', 'recipes', 'rental-check', 'meme-caption']) assert.ok(ids.has(id), `lists ${id}`);
+ const blog = r.body.creations.find((c) => c.id === 'blog-post');
+ assert.deepStrictEqual(blog, {
+ id: 'blog-post', product: 'blog', name: 'Write a blog post',
+ gives: 'A topic, and optionally a brief, a tone and who it is for.',
+ returns: 'A Markdown draft with a title, a standfirst and cited sources, saved as a draft you can edit.',
+ workflow: 'blog.draft_post', task: null,
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.blog/write',
+ });
+ const icon = r.body.creations.find((c) => c.id === 'icon');
+ assert.strictEqual(icon.task, 'design.icon');
+ assert.strictEqual(icon.workflow, 'ai.chat');
+ assert.strictEqual(icon.access.guest, true, 'the icon drawer lets a guest draw');
+});
+
+t.test('a preflight answers the same open CORS policy', async () => {
+ const r = await request(h.base, 'OPTIONS', '/api/v1/creations', { headers: { Origin: 'https://openvibe.exchange', 'Access-Control-Request-Method': 'GET' } });
+ assert.strictEqual(r.status, 204);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
+});
+
+t.test('the Markdown twin is the same list, grouped by product', async () => {
+ const md = await request(h.base, 'GET', '/api/v1/creations.md');
+ assert.strictEqual(md.status, 200);
+ assert.match(md.headers.get('content-type') || '', /^text\/markdown/);
+ assert.strictEqual(md.headers.get('access-control-allow-origin'), '*');
+ assert.match(md.text, /^# What AI can make on OpenVibe/m);
+ assert.ok(md.text.includes('## Blog') && md.text.includes('### Write a blog post'));
+ assert.ok(md.text.includes('- Start here: https://openvibe.blog/write'));
+ assert.ok(md.text.includes('task `design.icon`'));
+ const byQuery = await request(h.base, 'GET', '/api/v1/creations?format=markdown');
+ assert.match(byQuery.headers.get('content-type') || '', /^text\/markdown/);
+});
+
+t.test('the registered workflows and tasks agree with the catalogue', async () => {
+ const problems = creations.problems({ workflows: await h.registry.listWorkflows(), tasks: await h.choices.tasks() });
+ assert.deepStrictEqual(problems, []);
+});
+
+t.test('the code definitions agree before seeding too, so a new workflow cannot ship without a decision', async () => {
+ const definitions = ['live', 'core', 'products', 'devblog', 'media', 'chat', 'space', 'research', 'meme', 'changelog']
+ .flatMap((name) => require(`../server/workflows/${name}`).workflows);
+ const problems = creations.problems({ workflows: definitions.map((w) => ({ ...w, status: 'active' })), tasks: await h.choices.tasks() });
+ assert.deepStrictEqual(problems, []);
+});
+
+t.test('a product workflow without an entry fails the check; an entry naming an absent workflow fails too', async () => {
+ const workflows = await h.registry.listWorkflows();
+ const tasks = await h.choices.tasks();
+ const blog = workflows.find((w) => w.key === 'blog.draft_post');
+ const without = { ...blog, metadata: { ...blog.metadata, creation: undefined } };
+ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? without : w)), tasks }).some((p) => p.includes('blog.draft_post') && p.includes('metadata.creation')));
+ const mistyped = { ...blog, metadata: { ...blog.metadata, creation: { ...blog.metadata.creation, workflow: 'blog.not_a_workflow' } } };
+ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? mistyped : w)), tasks }).some((p) => p.includes('does not exist') || p.includes('must be')));
+ const unnamedTask = { ...blog, metadata: { ...blog.metadata, creation: { ...blog.metadata.creation, task: 'design.not_registered' } } };
+ assert.ok(creations.problems({ workflows: workflows.map((w) => (w.key === blog.key ? unnamedTask : w)), tasks }).some((p) => p.includes('not registered')));
+});
+
+t.test('only what is live is listed: a deprecated workflow leaves the list at once', async () => {
+ const workflow = await h.registry.activeWorkflow('blog.draft_post');
+ await h.registry.setStatus('workflow', 'blog.draft_post', workflow.version, 'deprecated', { actor: 'test' });
+ try {
+ const off = await request(h.base, 'GET', '/api/v1/creations');
+ assert.ok(!off.body.creations.some((c) => c.id === 'blog-post'), 'the deprecated creation is gone');
+ assert.ok(off.body.creations.length >= 10);
+ } finally {
+ await h.registry.setStatus('workflow', 'blog.draft_post', workflow.version, 'active', { actor: 'test' });
+ }
+ const on = await request(h.base, 'GET', '/api/v1/creations');
+ assert.ok(on.body.creations.some((c) => c.id === 'blog-post'), 'and comes back when active');
+});
+
+t.test('stop', async () => { await h.stop(); });
+
+t.run();
@@ -54,11 +54,21 @@ t.test('the exact page map, including the Markdown twins and the legal pages', a
@@ -54,11 +54,21 @@ t.test('the exact page map, including the Markdown twins and the legal pages', a
['/privacy', '/privacy'],
['/privacy', '/privacy'],
['/dmca', '/dmca'],
['/dmca', '/dmca'],
['/sitemap.xml', '/sitemap.xml'],
['/sitemap.xml', '/sitemap.xml'],
- ['/llms.txt', '/llms.txt'],
['/llms-full.txt', '/llms-full.txt'],
['/llms-full.txt', '/llms-full.txt'],
]) await moved(p, to);
]) await moved(p, to);
});
});
+t.test('/llms.txt stays on AI and carries the creation catalogue', async () => {
+ const r = await request(h.base, 'GET', '/llms.txt', { headers: { Accept: BROWSER }, redirect: 'manual' });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^text\/plain/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.match(r.text, /^# OpenVibe\.AI/m);
+ assert.ok(r.text.includes('## What AI can make'), 'names the catalogue section');
+ assert.ok(r.text.includes('/api/v1/creations'), 'names the endpoint');
+ assert.ok(r.text.includes('Write a blog post'), 'lists a live creation');
+});
+
t.test('an unknown human page goes to the Exchange home; an unknown API path stays a problem document', async () => {
t.test('an unknown human page goes to the Exchange home; an unknown API path stays a problem document', async () => {
await moved('/prices-and-latency', '/');
await moved('/prices-and-latency', '/');
await moved('/some-old-post', '/');
await moved('/some-old-post', '/');
@@ -78,7 +88,6 @@ t.test('console paths redirect to Exchange while machine and receipt routes stay
@@ -78,7 +88,6 @@ t.test('console paths redirect to Exchange while machine and receipt routes stay
assert.strictEqual(release.status, 200);
assert.strictEqual(release.status, 200);
await moved('/console', '/console');
await moved('/console', '/console');
await moved('/console/providers?tab=all', '/console/providers?tab=all');
await moved('/console/providers?tab=all', '/console/providers?tab=all');
- await moved('/llms.txt', '/llms.txt');
await moved('/auth/login', '/auth/login');
await moved('/auth/login', '/auth/login');
await moved('/you', '/privacy/keys');
await moved('/you', '/privacy/keys');
await moved('/you?allow=actor&provider=openai', '/privacy/keys?allow=actor&provider=openai');
await moved('/you?allow=actor&provider=openai', '/privacy/keys?allow=actor&provider=openai');