openvibers/openvibe.ai Commits

What AI can make: one public catalogue built from the registered workflows and tasks (#424)

OP OpenVibers committed 3 days ago · 44bd6aae83 · 16 files, +596 −24

* What AI can make: one public catalogue built from the registered workflows and tasks

GET /api/v1/creations (and /creations.md, and the same list in /llms.txt) answers every creation that is live now. The entry lives on the workflow that does the work (metadata.creation), so registering a workflow registers its catalogue; a task-only creation is declared once and must name a registered task. The check fails when a product workflow has neither a catalogue entry nor metadata.internal, or when an entry names a workflow or task that is absent, and only active workflows are listed.

* Merge remote-tracking branch 'origin/main' into feat/ai-creations

* Pin openvibe-contracts 0.387.0; keep the Markdown twin safe on an unknown product

* Document the creation catalogue in /openapi.json

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex b0574b7..361ce1b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 8720ee8..ea9bbb5 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex 46194e6..9787963 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, p
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
// 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, p
app.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 a
ddiff --git a/server/creations.js b/server/creations.js
nnew file mode 100644
iindex 0000000..ece5704
---- /dev/null
++++ b/server/creations.js
@@@ -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 };
ddiff --git a/server/home.js b/server/home.js
iindex bf28b29..4167e4a 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -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');
}
ddiff --git a/server/index.js b/server/index.js
iindex ded705f..a28e9de 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) {
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 1e30415..f04d170 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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: [],
ddiff --git a/server/workflows/changelog.js b/server/workflows/changelog.js
iindex 2c5119a..5816ffa 100644
---- a/server/workflows/changelog.js
++++ b/server/workflows/changelog.js
@@@ -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 };
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
iindex d3b6ea7..55e2fbe 100644
---- a/server/workflows/core.js
++++ b/server/workflows/core.js
@@@ -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/',
++ },
++ },
},
];
ddiff --git a/server/workflows/devblog.js b/server/workflows/devblog.js
iindex df9da8b..ceca299 100644
---- a/server/workflows/devblog.js
++++ b/server/workflows/devblog.js
@@@ -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) }) },
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 = [];
ddiff --git a/server/workflows/media.js b/server/workflows/media.js
iindex ce888e9..10cac08 100644
---- a/server/workflows/media.js
++++ b/server/workflows/media.js
@@@ -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/',
++ },
++ },
},
];
ddiff --git a/server/workflows/meme.js b/server/workflows/meme.js
iindex 7906e92..cdd581c 100644
---- a/server/workflows/meme.js
++++ b/server/workflows/meme.js
@@@ -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] };
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex 07b4fc1..d251b8f 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -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 };
ddiff --git a/server/workflows/space.js b/server/workflows/space.js
iindex 065246a..e71ed58 100644
---- a/server/workflows/space.js
++++ b/server/workflows/space.js
@@@ -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 },
},
];
ddiff --git a/test/creations.test.js b/test/creations.test.js
nnew file mode 100644
iindex 0000000..f272ea7
---- /dev/null
++++ b/test/creations.test.js
@@@ -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();
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 6660af0..f893cf4 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -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 stay
assert.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');