openvibers/openvibe.chat Commits
Vibe plans your day: my_day suggests, sets nothing until you say yes (L48 V3) (#332)
Changes
@@@ -333,7 +333,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, texif (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);@@@ -95,6 +95,7 @@ const SET_REMINDER = fn('set_reminder', 'Set a reminder for this person on OpenVconst 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; }@@@ -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) => {nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -52,8 +52,10 @@ t('a question starts a chat and Vibe answers it in the background; the next oneassert.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();nnew file mode 100644@@@ -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();@@@ -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();
@@ -333,7 +333,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
@@ -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);
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
// 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).
// 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);
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.
// 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);
const entry = vibeResults.ofTool(c.function.name, tool.result);
@@ -95,6 +95,7 @@ const SET_REMINDER = fn('set_reminder', 'Set a reminder for this person on OpenV
@@ -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 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.',
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']);
{ 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 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.',
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 } = {}) => [
@@ -117,7 +118,7 @@ const definitions = (member, room = null, { memory = false } = {}) => [
...(isVibe(member) ? [WEB] : []),
...(isVibe(member) ? [WEB] : []),
...(isWorkspace(room) ? [QUEUE, QUEUE_STATUS] : []),
...(isWorkspace(room) ? [QUEUE, QUEUE_STATUS] : []),
...(hasMemory(member, room, memory) ? [REMEMBER, FORGET] : []),
...(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; };
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) {
@@ -428,11 +429,14 @@ async function startTimer(args, ctx) {
rounds: Number.isFinite(rounds) && rounds >= 1 ? rounds : null,
rounds: Number.isFinite(rounds) && rounds >= 1 ? rounds : null,
}, { fetchImpl: ctx.fetchImpl });
}, { 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) {
async function stopTimer(_args, ctx) {
return await require('./vibe-timer').stop(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });
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
* 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 = {}) {
@@ -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))
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 === 'queue_task' || name === 'queue_status') && !isWorkspace(ctx.room))
|| ((name === 'remember' || name === 'forget') && !(hasMemory(ctx.member, ctx.room, ctx.memory) && ctx.user))
|| ((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)}` };
const error = { error: `no tool called ${String(name).slice(0, 64)}` };
return { text: clip(error), result: error };
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 = {};
let args = {};
if (raw) {
if (raw) {
try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; }
try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; }
@@ -52,9 +52,9 @@ const wire = (m) => {
@@ -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);
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
async function latest(user) {
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) => {
router.get('/chat', personOnly, async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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 };
@@ -187,6 +187,20 @@ async function list(user, { limit = 100 } = {}) {
@@ -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 }));
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
* 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.
* 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) {
@@ -258,4 +272,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
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 };
@@ -52,8 +52,10 @@ t('a question starts a chat and Vibe answers it in the background; the next one
@@ -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');
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 } });
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');
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 });
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 () => {
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 () =>
@@ -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('done', async () => { aiClient._setClient(null); await h.close(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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();
@@ -81,3 +81,5 @@ t('a plain "remind me …" is recognised; a question about reminders is not', as
@@ -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('done', async () => { await h.close(); });
+
+t.run();