openvibers/openvibe.chat Commits

Vibe plans your day: my_day suggests, sets nothing until you say yes (L48 V3) (#332)

OP OpenVibers committed 11 days ago · 624caf24af · 8 files, +217 −9

my_day (Vibe in a person's own chat) reads their next 24 hours from OpenVibe.Today: the agenda (own events, events
they set a reminder on, followed streams) with Chat's own today.agenda.read token naming the person, and their
reminders that fire in that time, with times in their zone. In the answer that read the day, set_reminder and
start_timer are refused ("suggest it and ask first"): Vibe suggests, and sets what the person agrees to when they
reply. The turn's called set now reaches the tools for that.

Two test files never ran (vibe-api, vibe-reminders: a suite with no t.run()). Running them found a real bug: the
Dock's "latest chat" was the one an answer last arrived in, so an older chat's late answer took a fresh chat's
place. It is now the chat the person last wrote in (vibe.current).

Changes

ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex f73b6f3..a3d29dd 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -333,7 +333,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
if (steps) require('./how').step(steps, c.function.name, c.function.arguments);
// The person's own words travel with the tool call: Vibe searches a shorter query than they wrote, and the
// tool still judges each result against the constraints they stated (11-01).
-- const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, requestText: text, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
++ const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, requestText: text, called, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
if (steps) require('./how').cite(steps, c.function.name, tool.result);
// What Vibe really did (a reminder or a timer it set or stopped): kept with the answer, as the chips.
const entry = vibeResults.ofTool(c.function.name, tool.result);
ddiff --git a/server/rooms/ai-tools.js b/server/rooms/ai-tools.js
iindex 0b29ecf..d619705 100644
---- a/server/rooms/ai-tools.js
++++ b/server/rooms/ai-tools.js
@@@ -95,6 +95,7 @@ const SET_REMINDER = fn('set_reminder', 'Set a reminder for this person on OpenV
const MY_REMINDERS = fn('my_reminders', 'This person\'s active reminders on OpenVibe.Today: each one\'s id, words, when it fires (in words) and its next time.');
const STOP_REMINDER = fn('stop_reminder', 'Stop one of this person\'s reminders, by its id (rem_…) from my_reminders, when they ask to cancel or stop it.',
{ id: { type: 'string', description: 'The reminder\'s id, rem_…' } }, ['id']);
++const MY_DAY = fn('my_day', 'This person\'s day on OpenVibe.Today, for "plan my day", "what\'s on today", "how does my day look": their events and followed streams in the next 24 hours and the reminders that fire in them, with times in their zone. Use it to suggest a plan around what is already there (what to do when, reminders, a focus timer). It sets nothing, and in the same answer you may not set anything either: suggest, ask, and set what they agree to when they reply.');
const MY_AGENTS = fn('my_agents', 'The OpenVibe Agents this person has: each one\'s name, when it runs, whether it is paused, its next run and its page.');
const START_TIMER = fn('start_timer', 'Start this person\'s timer in their Dock, the little floating surface every OpenVibe site draws: it counts down in the Dock\'s pill on every site they have open and rings at its end. Use it when they ask for a timer ("start a 25 minute timer", "set a timer for 10 minutes", "start a focus timer", "give me a pomodoro", "time me for 45 minutes"). With focus_rounds it becomes a focus block that alternates a focus round and a 5-minute break by itself. Never say a timer is running without calling this.',
{
@@@ -117,7 +118,7 @@ const definitions = (member, room = null, { memory = false } = {}) => [
...(isVibe(member) ? [WEB] : []),
...(isWorkspace(room) ? [QUEUE, QUEUE_STATUS] : []),
...(hasMemory(member, room, memory) ? [REMEMBER, FORGET] : []),
-- ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER, START_TIMER, STOP_TIMER] : []),
++ ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER, MY_DAY, START_TIMER, STOP_TIMER] : []),
];
const clip = (o) => { const s = typeof o === 'string' ? o : JSON.stringify(o); return s.length > RESULT_MAX ? `${s.slice(0, RESULT_MAX - 1)}…` : s; };
@@@ -428,11 +429,14 @@ async function startTimer(args, ctx) {
rounds: Number.isFinite(rounds) && rounds >= 1 ? rounds : null,
}, { fetchImpl: ctx.fetchImpl });
}
++async function myDay(_args, ctx) {
++ return await require('./vibe-day').day(subjectOf(ctx), { timezone: ctx.timezone || 'UTC', fetchImpl: ctx.fetchImpl });
++}
async function stopTimer(_args, ctx) {
return await require('./vibe-timer').stop(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });
}
--const RUNNERS = { search_openvibe: searchOpenvibe, read_page: readPage, web_search: webSearch, current_time: currentTime, roll_dice: rollDice, pick_one: pickOne, household_pantry: householdPantry, queue_task: queueTask, queue_status: queueStatus, remember, forget, set_up_agent: setUpAgent, my_agents: myAgents , set_reminder: setReminder, my_reminders: myReminders, stop_reminder: stopReminder, start_timer: startTimer, stop_timer: stopTimer };
++const RUNNERS = { search_openvibe: searchOpenvibe, read_page: readPage, web_search: webSearch, current_time: currentTime, roll_dice: rollDice, pick_one: pickOne, household_pantry: householdPantry, queue_task: queueTask, queue_status: queueStatus, remember, forget, set_up_agent: setUpAgent, my_agents: myAgents , set_reminder: setReminder, my_reminders: myReminders, stop_reminder: stopReminder, my_day: myDay, start_timer: startTimer, stop_timer: stopTimer };
/**
* Runs one tool call and returns both what the model reads (the JSON clipped to RESULT_MAX) and the whole result, so
@@@ -443,10 +447,16 @@ async function runWithResult(name, raw, ctx = {}) {
if (!runner || (name === 'household_pantry' && !(ctx.member && ctx.member.link_service === 'food')) || (name === 'web_search' && !isVibe(ctx.member))
|| ((name === 'queue_task' || name === 'queue_status') && !isWorkspace(ctx.room))
|| ((name === 'remember' || name === 'forget') && !(hasMemory(ctx.member, ctx.room, ctx.memory) && ctx.user))
-- || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder', 'start_timer', 'stop_timer'].includes(name) && !(ownChat(ctx.member, ctx.room) && ctx.user))) {
++ || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder', 'my_day', 'start_timer', 'stop_timer'].includes(name) && !(ownChat(ctx.member, ctx.room) && ctx.user))) {
const error = { error: `no tool called ${String(name).slice(0, 64)}` };
return { text: clip(error), result: error };
}
++ // Plan my day (L48 V3): a turn that read the day suggests; it never fills the calendar by itself. What the person
++ // agrees to is set in their next message.
++ if ((name === 'set_reminder' || name === 'start_timer') && ctx.called && ctx.called.has('my_day')) {
++ const error = { error: 'This answer read their day to suggest a plan: suggest it and ask first. Set what they agree to when they reply.' };
++ return { text: clip(error), result: error };
++ }
let args = {};
if (raw) {
try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; }
ddiff --git a/server/rooms/vibe-api.js b/server/rooms/vibe-api.js
iindex a1c57a4..c63e994 100644
---- a/server/rooms/vibe-api.js
++++ b/server/rooms/vibe-api.js
@@@ -52,9 +52,9 @@ const wire = (m) => {
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
async function latest(user) {
-- const list = await vibe.list(user, { limit: 1 });
-- const first = Array.isArray(list) ? list[0] : (list && list.chats ? list.chats[0] : null);
-- return first ? await vibe.ownChat(first.slug, user) : null;
++ // The chat they last wrote in (vibe.current): an answer arriving later in an older chat does not take its place.
++ const slug = await vibe.current(user);
++ return slug ? await vibe.ownChat(slug, user) : null;
}
router.get('/chat', personOnly, async (req, res) => {
ddiff --git a/server/rooms/vibe-day.js b/server/rooms/vibe-day.js
nnew file mode 100644
iindex 0000000..83d6d72
---- /dev/null
++++ b/server/rooms/vibe-day.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * "Plan my day" (plan L48 V3; the owner, 2026-11-01: "Vibe reads your day and suggests blocks and reminders. You
++ * confirm; it never fills your calendar alone"). Vibe's my_day tool reads the person's day from OpenVibe.Today:
++ *
++ * - the agenda (GET /api/v1/me/agenda?days=1): their own events, the events they set a reminder on, and the streams
++ * of people they follow, read with Chat's own service token (audience openvibe.today, scope today.agenda.read,
++ * contracts 0.280.0) naming the person with X-OV-Subject;
++ * - their reminders that fire in the next 24 hours (the reminders module, today.reminder.write).
++ *
++ * Read-only. A turn that read the day may not set a reminder or start a timer (ai-tools.js refuses it): Vibe suggests,
++ * and sets what the person agrees to in their next message.
++ *
++ * day(subject, { timezone }) → { now, timezone, events, reminders, follows_unavailable, plan_rule } | { error }
++ */
++const config = require('../config');
++const reminders = require('./vibe-reminders');
++
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DAY_MS = 24 * 60 * 60_000;
++
++let tokens = null;
++function tokenClient() {
++ if (tokens) return tokens;
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ // Its own token: asking for a scope Network has not granted would refuse a combined one whole.
++ tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.today', scope: 'today.agenda.read',
++ });
++ return tokens;
++}
++
++/** A time in the person's zone, the way Vibe should say it ("Mon 14:30"). */
++function local(iso, timezone) {
++ try {
++ return new Intl.DateTimeFormat('en-GB', { timeZone: timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso));
++ } catch { return String(iso || '').slice(0, 16).replace('T', ' '); }
++}
++
++async function agenda(subject, { fetchImpl = globalThis.fetch } = {}) {
++ if (!config.oauth.clientSecret) return { error: 'your day is not available on this server' };
++ let token;
++ try { token = await tokenClient().getToken(); } catch { return { error: 'your day is not available right now' }; }
++ try {
++ const res = await fetchImpl(`${config.today.internalUrl}/api/v1/me/agenda?days=1`, {
++ headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json' },
++ signal: AbortSignal.timeout(10_000),
++ });
++ const json = await res.json().catch(() => null);
++ if (!res.ok || !json) return { error: `OpenVibe.Today answered ${res.status}` };
++ return { json };
++ } catch { return { error: 'OpenVibe.Today is unreachable' }; }
++}
++
++async function day(subject, { timezone = 'UTC', now = Date.now(), fetchImpl } = {}) {
++ if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };
++ const tz = String(timezone || 'UTC').slice(0, 64);
++ const [a, r] = await Promise.all([agenda(subject, { fetchImpl }), reminders.list(subject, { fetchImpl })]);
++ if (a.error && r.error) return { error: a.error };
++ const until = now + DAY_MS;
++ const events = a.error ? [] : (a.json.events || [])
++ .filter((e) => { const t = Date.parse(e.starts_at); return t >= now - 2 * 60 * 60_000 && t <= until; })
++ .slice(0, 25)
++ .map((e) => ({
++ title: String(e.title || '').slice(0, 120), at: local(e.starts_at, tz), starts_at: e.starts_at,
++ ends_at: e.ends_at || null, kind: e.kind || null, channel: e.channel || null, url: e.url || null,
++ yours: !!e.own, reminded: !!e.reminded,
++ }));
++ const due = r.error ? [] : (r.reminders || [])
++ .filter((x) => x.next_at && Date.parse(x.next_at) <= until)
++ .map((x) => ({ id: x.id, title: x.title, when: x.when, next: local(x.next_at, tz), repeats: x.repeats }));
++ return {
++ now: local(new Date(now).toISOString(), tz),
++ timezone: tz,
++ events,
++ reminders: due,
++ follows_unavailable: a.error ? true : !!a.json.follows_unavailable,
++ ...(a.error ? { agenda_error: a.error } : {}),
++ ...(r.error ? { reminders_error: r.error } : {}),
++ plan_rule: 'Suggest a plan in their time zone around what is already there: what to do when, and which reminders or a focus timer would help. Set nothing in this answer; ask whether to add them, and set what they agree to when they say yes.',
++ manage: 'https://openvibe.today/me/week',
++ };
++}
++
++function _setTokens(t) { tokens = t; }
++
++module.exports = { day, local, _setTokens };
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex 6ea74ab..e80b92d 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -187,6 +187,20 @@ async function list(user, { limit = 100 } = {}) {
return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at }));
}
++/**
++ * The chat a person is in: the one they last wrote in, not the one an answer last arrived in. A Vibe answer that lands
++ * in an older chat after they started a fresh one (asked, then "New chat" before the answer came) must not take the
++ * fresh one's place in the Dock. A chat they have not written in yet (made fresh, empty) counts from when it was made.
++ */
++async function current(user) {
++ if (!user) return null;
++ const row = await db.get(`SELECT r.slug FROM rooms r
++ WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL
++ ORDER BY GREATEST(r.created_at, COALESCE((SELECT MAX(m.created_at) FROM room_messages m WHERE m.room_id = r.id AND m.ai_member_id IS NULL), r.created_at)) DESC, r.id DESC
++ LIMIT 1`, [user.id]);
++ return row ? row.slug : null;
++}
++
/**
* Search chats (the rail's box): the person's own chats whose name or any message holds `q`, ignoring case, newest
* first, each with its newest matching message as `hit` (null when only the name matched). Under 2 characters: none.
@@@ -258,4 +272,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
--module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
++module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
ddiff --git a/test/vibe-api.test.js b/test/vibe-api.test.js
iindex 849e64d..c3227a9 100644
---- a/test/vibe-api.test.js
++++ b/test/vibe-api.test.js
@@@ -52,8 +52,10 @@ t('a question starts a chat and Vibe answers it in the background; the next one
assert.strictEqual(again.body.chat.slug, chat.slug, 'within six hours: the same chat');
const fresh = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Something new', fresh: true } });
assert.notStrictEqual(fresh.body.chat.slug, chat.slug, 'fresh: a new chat');
++ // The older chat's answer to "And reminders?" lands after the fresh chat was started: the fresh one stays current.
++ await answerAfter(ann.token, chat.slug, again.body.message.id);
const latest = await h.http('GET', '/api/vibe/chat', { token: ann.token });
-- assert.strictEqual(latest.body.chat.slug, fresh.body.chat.slug, 'the latest chat first');
++ assert.strictEqual(latest.body.chat.slug, fresh.body.chat.slug, 'the chat they last wrote in, not the one an answer last arrived in');
});
t('a chat is its person\'s alone, and an empty message is refused', async () => {
@@@ -66,3 +68,5 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
});
t('done', async () => { aiClient._setClient(null); await h.close(); });
++
++t.run();
ddiff --git a/test/vibe-plan-my-day.test.js b/test/vibe-plan-my-day.test.js
nnew file mode 100644
iindex 0000000..24f0f74
---- /dev/null
++++ b/test/vibe-plan-my-day.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * "Plan my day" (plan L48 V3; server/rooms/vibe-day.js and the my_day tool): only Vibe in its person's own chat has it;
++ * it reads the person's day from OpenVibe.Today as that person (Chat's own today.agenda.read token, X-OV-Subject) with
++ * their reminders that fire in the next 24 hours, times in their zone; and in the answer that read the day, Vibe may
++ * not set a reminder or start a timer: it suggests, and sets what the person agrees to when they reply.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++let aiTools, day, reminders;
++
++const t = suite('vibe plan my day');
++let h, ann;
++const sent = [];
++const soon = (min) => new Date(Date.now() + min * 60_000).toISOString();
++const todayFetch = async (url, o = {}) => {
++ const u = String(url);
++ sent.push({ url: u, method: o.method || 'GET', subject: o.headers['X-OV-Subject'], auth: o.headers.Authorization });
++ if (/\/api\/v1\/me\/agenda\?days=1$/.test(u)) {
++ return { ok: true, status: 200, json: async () => ({ from: soon(-120), to: soon(1440), days: 1, follows_unavailable: false, events: [
++ { id: 'evt_1', title: 'Dentist', starts_at: soon(90), ends_at: soon(150), kind: 'event', own: true, reminded: false, url: null },
++ { id: 'evt_2', title: 'Tokyo walk', starts_at: soon(300), kind: 'stream', channel: 'japaneseoldguy', own: false, reminded: true, url: 'https://openvibe.live/@japaneseoldguy' },
++ { id: 'evt_3', title: 'Next week', starts_at: soon(60 * 24 * 3), kind: 'event', own: true },
++ ] }) };
++ }
++ if (/\/api\/v1\/reminders$/.test(u)) {
++ return { ok: true, status: 200, json: async () => ({ reminders: [
++ { id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ0', title: 'Stretch', summary: 'Every 2 hours', next_at: soon(45), repeats: true, status: 'active' },
++ { id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ1', title: 'Pay rent', summary: 'On the 1st', next_at: soon(60 * 24 * 20), repeats: true, status: 'active' },
++ ] }) };
++ }
++ return { ok: false, status: 404, json: async () => ({}) };
++};
++const vibeMember = { handle: 'vibe' };
++const ownChat = { id: 1, assistant: 1 };
++const run = async (name, args, ctx = {}) => JSON.parse(await aiTools.run(name, JSON.stringify(args), { member: vibeMember, room: ownChat, user: h.live.users.get(ann.id), fetchImpl: todayFetch, timezone: 'Asia/Tokyo', ...ctx }));
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ aiTools = require('../server/rooms/ai-tools');
++ day = require('../server/rooms/vibe-day');
++ reminders = require('../server/rooms/vibe-reminders');
++ day._setTokens({ getToken: async () => 'chat-agenda-token' });
++ reminders._setTokens({ getToken: async () => 'chat-reminder-token' });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++});
++
++t('only Vibe in its person\'s own chat has my_day', async () => {
++ const names = (m, r) => aiTools.definitions(m, r).map((d) => d.function.name);
++ assert.ok(names(vibeMember, ownChat).includes('my_day'));
++ assert.ok(!names(vibeMember, { id: 2, assistant: 0 }).includes('my_day'), 'Vibe added to a room: never');
++ assert.ok(!names({ handle: 'dealer' }, ownChat).includes('my_day'), 'another AI member: never');
++ assert.match((await run('my_day', {}, { room: { id: 2, assistant: 0 } })).error, /no tool called my_day/);
++});
++
++t('my_day reads the next 24 hours as the person: events and reminders, in their zone', async () => {
++ const out = await run('my_day', {});
++ const agenda = sent.find((c) => /me\/agenda/.test(c.url));
++ assert.strictEqual(agenda.subject, h.live.users.get(ann.id).subject_id, 'X-OV-Subject is the person');
++ assert.strictEqual(agenda.auth, 'Bearer chat-agenda-token', 'its own today.agenda.read token');
++ assert.deepStrictEqual(out.events.map((e) => e.title), ['Dentist', 'Tokyo walk'], 'next week is not today');
++ assert.strictEqual(out.events[0].yours, true);
++ assert.strictEqual(out.events[1].reminded, true);
++ assert.deepStrictEqual(out.reminders.map((r) => r.title), ['Stretch'], 'the rent reminder fires in 20 days, not today');
++ assert.strictEqual(out.timezone, 'Asia/Tokyo');
++ assert.match(out.events[0].at, /^[A-Z][a-z]{2} \d{2}:\d{2}$/, 'a time in their zone, the way Vibe should say it');
++ assert.match(out.plan_rule, /Set nothing in this answer/);
++ assert.strictEqual(out.manage, 'https://openvibe.today/me/week');
++});
++
++t('in the answer that read the day, nothing is set: Vibe suggests and asks first', async () => {
++ const called = new Set(['my_day', 'set_reminder']);
++ const set = await run('set_reminder', { text: 'stretch every 2 hours' }, { called });
++ assert.match(set.error, /suggest it and ask first/);
++ const timer = await run('start_timer', { minutes: 25 }, { called: new Set(['my_day', 'start_timer']) });
++ assert.match(timer.error, /suggest it and ask first/);
++ assert.ok(!sent.some((c) => c.method === 'POST'), 'nothing reached Today');
++});
++
++t('when Today cannot answer, Vibe is told so plainly', async () => {
++ const down = async () => { throw new Error('ECONNREFUSED'); };
++ const out = await run('my_day', {}, { fetchImpl: down });
++ assert.match(out.error, /unreachable/);
++});
++
++t('done', async () => { await h.close(); });
++
++t.run();
ddiff --git a/test/vibe-reminders.test.js b/test/vibe-reminders.test.js
iindex 3ff044d..cd7ae22 100644
---- a/test/vibe-reminders.test.js
++++ b/test/vibe-reminders.test.js
@@@ -81,3 +81,5 @@ t('a plain "remind me …" is recognised; a question about reminders is not', as
});
t('done', async () => { await h.close(); });
++
++t.run();