openvibers/openvibe.codes Commits

An OpenVibe site, picked from your words: Codes works out which projects a request is about (#222)

OP OpenVibers committed 12 days ago · a3dc312165 · 6 files, +191 −10

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 007a91e..32a4d7c 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -187,6 +187,10 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-recent{display:flex;gap:6px;margin:8px 0 0;overflow-x:auto;scrollbar-width:none}
.ch-recent .ch-chip{flex:none;font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
.ch-recent .ch-chip-new{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 40%,var(--ch-line))}
++/* An OpenVibe site picked from the words: which projects Codes will work on. */
++.ch-picked{display:flex;align-items:flex-start;gap:7px;margin:6px 2px 0;font-size:12.5px;line-height:1.45;color:var(--ch-soft)}
++.ch-picked .ch-i{width:15px;height:15px;margin-top:1px;color:var(--ch-accent)}
++.ch-picked b{color:var(--ch-ink);font-weight:650}
/* Where the work runs, under the bar. */
.ch-where{margin:6px 2px 0;font-size:12.5px;line-height:1.45;color:var(--ch-soft)}
.ch-where>span{display:flex;align-items:flex-start;gap:7px}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex d85dce1..a7f248a 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -62,7 +62,40 @@
if (rail && rail.dataset.chShown !== mode) { rail.dataset.chShown = mode; fill(mode); }
if (focus && text) text.focus();
}
-- modes.forEach((r) => r.addEventListener('change', () => { if (r.checked) choose(r.value, { focus: true }); }));
++ modes.forEach((r) => r.addEventListener('change', () => { if (r.checked) { choose(r.value, { focus: true }); pick(); } }));
++
++ // An OpenVibe site, picked from the words: the line under the bar says which projects Codes will work on, as
++ // the person types (GET /chat/match; the server decides again when the message is sent).
++ const picked = $('[data-ch-picked]', form);
++ const pickedText = picked && $('[data-ch-picked-text]', picked);
++ const projectSel = $('[data-ch-project]', form);
++ const DEFAULT_PICK = pickedText ? pickedText.textContent : '';
++ let pickTimer = 0, pickSeq = 0;
++ const list = (names) => (names.length < 2 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
++ const say = (parts) => { if (!pickedText) return; pickedText.textContent = ''; parts.forEach((p) => pickedText.append(typeof p === 'string' ? document.createTextNode(p) : p)); };
++ const bold = (t) => { const b = document.createElement('b'); b.textContent = t; return b; };
++ function pick() {
++ if (!picked || !projectSel) return;
++ const mode = (modes.find((r) => r.checked) || {}).value;
++ if (mode !== 'openvibe') return;
++ if (projectSel.value !== 'auto') { say(['You picked ', bold(projectSel.selectedOptions[0] ? projectSel.selectedOptions[0].textContent.split(' · ')[0] : projectSel.value), '. Choose "Codes picks for you" to let it work out the projects from your words.']); return; }
++ const q = text.value.trim();
++ clearTimeout(pickTimer);
++ if (!q) { say([DEFAULT_PICK]); return; }
++ pickTimer = setTimeout(async () => {
++ const mine = ++pickSeq;
++ try {
++ const res = await fetch(`${picked.dataset.chMatchUrl}?text=${encodeURIComponent(q.slice(0, 2000))}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
++ if (!res.ok || mine !== pickSeq) return;
++ const names = ((await res.json()).projects || []).map((p) => p.name);
++ if (mine !== pickSeq) return;
++ if (!names.length) say(['No site named yet: say which one (like Live, Chat or Games, or "the navbar"), or pick it from the list.']);
++ else say(['Codes will work on ', bold(list(names)), names.length > 1 ? ': one task each, told about the others.' : '.']);
++ } catch { /* the server decides when it is sent */ }
++ }, 300);
++ }
++ if (text) text.addEventListener('input', pick);
++ if (projectSel) projectSel.addEventListener('change', pick);
const current = modes.find((r) => r.checked);
if (rail && current) rail.dataset.chShown = current.value;
pace();
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex bb90056..0ab6182 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -410,7 +410,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const repo = asked || (repos[0] && repos[0].url) || '';
const mode = start.MODES.includes(req.query.mode) ? req.query.mode : asked ? 'code' : repos.length ? 'code' : 'new';
const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
-- const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'OpenVibers/OpenVibe.Live';
++ const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
const error = String(req.query.error || '').slice(0, 200);
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
@@@ -425,13 +425,14 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
// The pickers: each shows for the way it belongs to (CSS reads the checked card).
const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind>${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
-- const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents' }) + effortPicker('', 'ch-for-agents');
// The repositories a person used last are a tap away (the box also searches every one they can reach).
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
-- const where = `${recent}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built on OpenVibe’s servers at <b>openvibe.space</b> <span class="ch-where-tail">and you get a live link; you check it before anything is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
++ const picked = `<p class="ch-picked ch-for-openvibe" data-ch-picked data-ch-match-url="/chat/match" aria-live="polite">${ico('ov')}<span data-ch-picked-text>Say what you want changed and which site, in your own words: Codes works out which projects to change.</span></p>`;
++ const where = `${recent}${picked}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built on OpenVibe’s servers at <b>openvibe.space</b> <span class="ch-where-tail">and you get a live link; you check it before anything is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
// The ideas rail: it drifts sideways, stops under a pointer or the keyboard, and has its own pause. A press fills
// the box (and picks the way it belongs to), ready to be changed. The second copy is only there for the loop.
@@@ -448,6 +449,13 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
});
});
++ /** Which OpenVibe projects a request is about (start.matchProjects), for the composer as the person types. */
++ r.get('/chat/match', (req, res) => {
++ if (!viewer(req)) return res.status(401).json({ projects: [] });
++ const projects = start.matchProjects(String(req.query.text || '').slice(0, 4000)).map((p) => ({ name: p.name, repository: p.repository }));
++ return res.set('Cache-Control', 'no-store').json({ projects });
++ });
++
/** Start a conversation: the brief becomes a task on the person's project, and the page is its conversation. */
r.post('/chat', form, async (req, res) => {
if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2F');
@@@ -465,10 +473,18 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
// until the person presses Build, so a handed-over description is never spent behind their back.
if (mode === 'new') return res.redirect(303, start.spaceHref({ kind: b.kind, text }));
let repoUrl = '';
++ let targets = null; // several OpenVibe projects for one request: one task each, the first one opens
if (mode === 'openvibe') {
-- const p = start.project(b.project);
-- if (!p) return back('Pick one of the OpenVibe projects from the list.');
-- repoUrl = p.url;
++ if (!b.project || b.project === 'auto') {
++ const found = start.matchProjects(text);
++ if (!found.length) return back('Say which OpenVibe site this is for, in your own words (like Live, Chat or Games, or "the navbar"), or pick it from the list.');
++ targets = found;
++ repoUrl = found[0].url;
++ } else {
++ const p = start.project(b.project);
++ if (!p) return back('Pick one of the OpenVibe projects from the list.');
++ repoUrl = p.url;
++ }
} else {
repoUrl = start.normalizeRepo(b.repo);
if (!repoUrl) return back(String(b.repo || '').trim() ? 'That is not a Git address Codes can clone: use https://…, git@host:owner/name or owner/name, with no password in it.' : 'Pick a repository (owner/name or any Git URL), choose an OpenVibe site, or start something new.');
@@@ -477,14 +493,27 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
if (!who) return back('Choose who works on it from the list.');
const agent = who.agent || '';
let args;
++ const { parseArgs } = require('../../harness/job');
++ const jobInput = (repo, prompt) => JSON.stringify({ repo, prompt, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) });
try {
-- const { parseArgs } = require('../../harness/job');
-- args = parseArgs(JSON.stringify({ repo: repoUrl, prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
++ args = parseArgs(jobInput(repoUrl, text));
} catch (e) { return back(`Check the repository: ${e.message}.`); }
let project = null;
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');
if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});
++ if (targets && targets.length > 1) {
++ // One request across several projects: a task for each, each told what the others are, so the parts agree.
++ const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));
++ let first = null;
++ for (const [i, x] of targets.entries()) {
++ const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
++ const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each });
++ if (!first) first = row;
++ }
++ return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
++ }
const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
});
ddiff --git a/server/http/start.js b/server/http/start.js
iindex 2557f4e..aacc1b3 100644
---- a/server/http/start.js
++++ b/server/http/start.js
@@@ -102,6 +102,83 @@ const PROJECTS = (() => {
})();
const project = (repository) => PROJECTS.find((p) => p.repository === String(repository || '')) || null;
++/**
++ * Which OpenVibe projects a request is about, from the words alone (the owner, 2026-11-01: "it can just automatically
++ * figure out which repositories it needs to work on based on your request… people aren't going to know… they should
++ * just explain it: I want to do this or that to this website or these websites").
++ *
++ * Many names are plain words ("live", "chat", "food", "work"), so the signals are weighed:
++ * 3 the site's domain ("openvibe.live") or "OpenVibe Live" / "OpenVibe.Live"
++ * 2 the name written as a name ("Live", "Chat") anywhere but the first word, or a topic only one project owns
++ * ("go live", "sign in", "navbar", "coins")
++ * 1 the bare word ("live", "chat"): counted only when nothing stronger was said
++ * → up to three projects, strongest first: [{ repository, name, url, why }]. [] when the words name none.
++ */
++const TOPICS = {
++ 'OpenVibers/OpenVibe.Live': ['go live', 'going live', 'livestream', 'live stream', 'streaming', 'streamer', 'broadcast', 'obs', 'vod', 'vods', 'clips?', 'whip', 'rtmp', 'stream key', 'channel page', 'the player'],
++ 'OpenVibers/OpenVibe.Chat': ['chat', 'chats', 'dms?', 'direct messages?', 'emotes?', 'chat box', 'moderators?', 'mods'],
++ 'OpenVibers/OpenVibe.Network': ['sign in', 'sign-in', 'signing in', 'log in', 'login', 'logout', 'account', 'accounts', 'profile', 'password', 'two-factor', '2fa', 'notifications', 'my page', 'followers?', 'following'],
++ 'OpenVibers/OpenVibe.Shared': ['navbar', 'nav bar', 'top bar', 'menu bar', 'footer', 'the logo', 'site switcher', 'every site', 'all sites', 'all the sites', 'across the network', 'everywhere'],
++ 'OpenVibers/OpenVibe.Design': ['themes?', 'colou?rs', 'dark mode', 'light mode', 'fonts?', 'the look', 'icons'],
++ 'OpenVibers/OpenVibe.Billing': ['payments?', 'pay', 'checkout', 'coins', 'opencoins', 'vibes', 'cash ?out', 'refunds?', 'prices?'],
++ 'OpenVibers/OpenVibe.Tips': ['tips?', 'tipping', 'donations?', 'donate', 'paid messages?', 'alerts?'],
++ 'OpenVibers/OpenVibe.VIP': ['memberships?', 'subscriptions?', 'subscribers?', 'subs', 'vip'],
++ 'OpenVibers/OpenVibe.Games': ['games?', 'gaming', 'game worlds?', 'leaderboards?'],
++ 'OpenVibers/OpenVibe.Media': ['uploads?', 'recordings?', 'thumbnails?', 'video files?'],
++ 'OpenVibers/OpenVibe.Space': ['spaces?', 'no-code', 'app builder', 'website builder'],
++ 'OpenVibers/OpenVibe.Codes': ['codes', 'coding agents?', 'vibe coding', 'this page', 'the composer'],
++ 'OpenVibers/OpenVibe.Search': ['search', 'searching', 'search results'],
++ 'OpenVibers/OpenVibe.AI': ['ai models?', 'the ai', 'models', 'api keys?'],
++ 'OpenVibers/OpenVibe.Bot': ['robots?', 'robot', 'drive the robot'],
++ 'OpenVibers/OpenRestream': ['restream', 'restreaming', 'twitch', 'youtube', 'kick'],
++ 'OpenVibers/OpenVibe.Community': ['pastes?', 'posts?', 'forums?', 'comments?'],
++ 'OpenVibers/OpenVibe.Wiki': ['wiki', 'articles?'],
++ 'OpenVibers/OpenVibe.News': ['news', 'headlines?'],
++ 'OpenVibers/OpenVibe.Food': ['food', 'recipes?', 'restaurants?', 'what to eat'],
++ 'OpenVibers/OpenVibe.Work': ['jobs board', 'job listings?', 'hiring', 'resumes?'],
++ 'OpenVibers/OpenVibe.Rent': ['rentals?', 'apartments?', 'listings to rent'],
++ 'OpenVibers/OpenVibe.Help': ['help pages?', 'faq', 'support'],
++ 'OpenVibers/OpenVibe.Tools': ['online tools?', 'converters?', 'calculators?', 'whois'],
++ 'OpenVibers/OpenVibe.Meme': ['memes?'],
++ 'OpenVibers/OpenVibe.Today': ['holidays?', 'on this day', 'today page'],
++ 'OpenVibers/OpenVibe.Watch': ['price alerts?', 'watch for changes', 'change alerts?'],
++ 'OpenVibers/OpenVibe.Quest': ['quests?', 'badges?', 'achievements?'],
++ 'OpenVibers/OpenVibe.Report': ['stats', 'statistics', 'analytics', 'numbers', 'the rundown'],
++};
++const escRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++const MATCHERS = PROJECTS.map((p) => {
++ const short = p.repository.replace(/^OpenVibers\//, ''); // OpenVibe.Live, OpenRestream
++ const bare = String(p.name || '').trim();
++ const domains = [p.domain, short.toLowerCase()].filter(Boolean).map((d) => escRe(String(d).toLowerCase()));
++ const word = escRe(bare);
++ return {
++ p,
++ strong: new RegExp(`(?:\\b(?:${domains.join('|')})\\b|\\bopenvibe[ .]${word}\\b)`, 'i'),
++ named: bare.length > 1 ? new RegExp(`(?<!^)(?<![.!?]\\s)\\b${word}\\b`) : null, // "Live", not the first word
++ topic: TOPICS[p.repository] ? new RegExp(`\\b(?:${TOPICS[p.repository].join('|')})\\b`, 'i') : null,
++ bare: bare.length > 2 ? new RegExp(`\\b${escRe(bare.toLowerCase())}\\b`, 'i') : null,
++ };
++});
++function matchProjects(text, { max = 3 } = {}) {
++ const t = String(text || '').slice(0, 4000);
++ if (!t.trim()) return [];
++ const scored = MATCHERS.map((m) => {
++ let score = 0;
++ let why = '';
++ if (m.strong.test(t)) { score = 3; why = 'named'; }
++ else if (m.named && m.named.test(t)) { score = 2; why = 'named'; }
++ else if (m.topic && m.topic.test(t)) { score = 2; why = 'topic'; }
++ else if (m.bare && m.bare.test(t)) { score = 1; why = 'word'; }
++ return { m, score, why };
++ }).filter((x) => x.score > 0);
++ if (!scored.length) return [];
++ const top = Math.max(...scored.map((x) => x.score));
++ // A bare word counts only when nothing stronger was said; otherwise keep everything at 2 or more.
++ const kept = scored.filter((x) => (top >= 2 ? x.score >= 2 : true));
++ kept.sort((a, b) => b.score - a.score || PROJECTS.indexOf(a.m.p) - PROJECTS.indexOf(b.m.p));
++ return kept.slice(0, max).map(({ m, why }) => ({ repository: m.p.repository, name: m.p.name, url: m.p.url, why }));
++}
++
/**
* Any Git address the person types, as the https URL a task clones. "owner/name" is a GitHub repository; an ssh address
* (git@host:owner/name.git or ssh://git@host/owner/name) becomes its https twin; a bare host/path gains https://.
@@@ -139,4 +216,4 @@ function spaceHref({ kind, text, origin = SPACE }) {
return href;
}
--module.exports = { MODES, KINDS, IDEAS, ideasFor, PROJECTS, project, normalizeRepo, spaceHref, SPACE };
++module.exports = { MODES, KINDS, IDEAS, ideasFor, PROJECTS, project, matchProjects, normalizeRepo, spaceHref, SPACE };
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 2682a68..80424de 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -129,6 +129,31 @@ const { stepKind } = require('../server/http/chat');
assert.match(none.headers.get('location'), /error=Pick%20a%20repository/, 'no repository is asked for in words');
});
++ await check('an OpenVibe site picked from the words: one project, several, or a question when none is named', async () => {
++ const home = await t.get('/', { as: ada });
++ assert.match(home.text, /<select name="project" data-ch-project><option value="auto" selected>Codes picks for you<\/option>/, 'the default is to work it out');
++ assert.match(home.text, /data-ch-picked data-ch-match-url="\/chat\/match"/);
++ const m = await t.get(`/chat/match?text=${encodeURIComponent('Make the navbar smaller')}`, { as: ada });
++ assert.deepStrictEqual(JSON.parse(m.text).projects.map((p) => p.name), ['Shared']);
++ assert.strictEqual((await t.get('/chat/match?text=x')).status, 401, 'a visitor gets nothing');
++
++ const one = await t.get('/chat', { as: ada, form: { mode: 'openvibe', project: 'auto', text: 'Make the navbar smaller on phones' } });
++ const oneRow = await db.maybe('SELECT repo FROM run_tasks WHERE id = $1', [one.headers.get('location').split('/').pop()]);
++ assert.strictEqual(oneRow.repo, 'https://github.com/OpenVibers/OpenVibe.Shared');
++
++ const before = await db.maybe('SELECT count(*)::int AS n FROM run_tasks WHERE subject = $1', [ada.subject]);
++ const two = await t.get('/chat', { as: ada, form: { mode: 'openvibe', project: 'auto', text: 'Add a leaderboard to Games and show it on Live' } });
++ assert.match(two.headers.get('location'), /^\/tasks\/tsk_/);
++ const made = await db.many('SELECT repo, prompt FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 2', [ada.subject]);
++ const after = await db.maybe('SELECT count(*)::int AS n FROM run_tasks WHERE subject = $1', [ada.subject]);
++ assert.strictEqual(after.n - before.n, 2, 'one task per project');
++ assert.deepStrictEqual(made.map((r) => r.repo).sort(), ['https://github.com/OpenVibers/OpenVibe.Games', 'https://github.com/OpenVibers/OpenVibe.Live']);
++ assert.ok(made.every((r) => /This request spans OpenVibe\.Games, OpenVibe\.Live/.test(r.prompt)), 'each knows about the other');
++
++ const none = await t.get('/chat', { as: ada, form: { mode: 'openvibe', project: 'auto', text: 'make the home page load faster' } });
++ assert.match(decodeURIComponent(none.headers.get('location')), /Say which OpenVibe site this is for/);
++ });
++
await check('the effort a person picks rides on the first attempt, and the thread says it in words', async () => {
const made = await t.get('/chat', { as: ada, form: { text: 'Think hard about the cache', repo: 'https://github.com/OpenVibers/OpenVibe.Live', effort: 'high' } });
assert.strictEqual(made.status, 303);
ddiff --git a/test/start.test.js b/test/start.test.js
iindex 30e0a87..1e5c087 100644
---- a/test/start.test.js
++++ b/test/start.test.js
@@@ -56,6 +56,19 @@ const start = require('../server/http/start');
start.PROJECTS.forEach((p) => assert.match(p.url, /^https:\/\/github\.com\/OpenVibers\/[A-Za-z0-9._-]+$/, p.repository));
});
++ await check('the projects a request is about come from its own words (owner, 11-01)', async () => {
++ const names = (t) => start.matchProjects(t).map((p) => p.name);
++ assert.deepStrictEqual(names('Make the navbar smaller on phones'), ['Shared'], 'a topic only one project owns');
++ assert.deepStrictEqual(names('Fix the sign in page'), ['Network']);
++ assert.deepStrictEqual(names('Add a leaderboard to Games and show it on Live'), ['Games', 'Live'], 'two sites named: both');
++ assert.ok(names('Add a dark mode to openvibe.live')[0] === 'Live', 'the domain leads');
++ assert.ok(names('Let me drive the robot from Live').includes('Bot') && names('Let me drive the robot from Live').includes('Live'));
++ assert.deepStrictEqual(names('make the home page load faster'), [], 'no site named, none guessed');
++ assert.deepStrictEqual(names('I want a better world'), [], 'a plain word is not a project');
++ assert.ok(start.matchProjects('Live Chat Games Tips Food News Wiki and Rent').length <= 3, 'three at most');
++ assert.deepStrictEqual(names(''), []);
++ });
++
await check('the ideas rail: something new mixes the three kinds, every way has its own ideas', async () => {
const fresh = start.ideasFor('new');
assert.deepStrictEqual(fresh.slice(0, 3).map((i) => i.kind), ['website', 'game', 'app']);