openvibers/openvibe.chat Commits
Vibe sets reminders: set_reminder, my_reminders, stop_reminder on OpenVibe.Today (L48 V1) (#282)
Changes
@@@ -81,6 +81,8 @@ module.exports = {// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):// tasks for the person, with Chat's own token.actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },++ // OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.++ today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.web: {@@@ -414,6 +414,14 @@ async function afterPost(room, message, user, deps = {}) {const note = vibeMemory.fromRequest(message.message);if (note && (await vibeMemory.add(user, note, { source: 'vibe', roomId: room.id }).catch(() => ({}))).note && steps) require('./how').step(steps, 'remember');}++ // A plain "remind me …" is set even when the model only promised it (small models often do); the answer says so.++ if (vibe && text && Number(room.owner_id) === Number(user.id) && !called.has('set_reminder')) {++ const words = require('./vibe-reminders').fromRequest(message.message);++ if (words) {++ const set = await require('./vibe-reminders').create(user.subject_id || null, { text: words, timezone: deps.timezone || null }).catch(() => ({}));++ if (set && set.reminder) text = `${text}\n\n⏰ Reminder set: **${set.reminder.title}**, ${String(set.reminder.when).replace(/^./, (c) => c.toLowerCase())}. Snooze or stop it on [openvibe.today](https://openvibe.today/me#reminders).`;++ }++ }if (!text) {const out = await aiClient.structured('chat.member.reply', {ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),@@@ -21,6 +21,9 @@* queue_status in a workspace room: what is queued, running and recently done* remember / forget Vibe in a person's own chat with it, while their memory is on (server/rooms/vibe-memory.js):* keep a short note across their chats, or delete one by its id++ * set_reminder Vibe in a person's own chat: a reminder on OpenVibe.Today from their own words ("stretch every 2++ * hours from 9 to 5"), sent to their bell and push on every site (server/rooms/vibe-reminders.js);++ * my_reminders lists them and stop_reminder stops one* set_up_agent Vibe in a person's own chat: one of their OpenVibe Agents for recurring work, daily or weekly, once* they agreed (server/rooms/vibe-agents.js); my_agents lists the ones they have*@@@ -85,6 +88,11 @@ const SET_UP_AGENT = fn('set_up_agent', 'Set up recurring work for this person atime_utc: { type: 'string', description: 'When it runs, HH:MM in UTC' },weekday: { type: 'integer', minimum: 0, maximum: 6, description: 'For weekly: 0 Sunday to 6 Saturday' },}, ['name', 'instructions', 'schedule', 'time_utc']);++const SET_REMINDER = fn('set_reminder', 'Set a reminder for this person on OpenVibe.Today, from what they said: what to remind them of and when, in plain words ("stretch every 2 hours from 9 to 5", "take my meds every day at 9am and 9pm", "call mom on Sunday at 6pm", "check the oven in 20 minutes", "pay rent on the 1st of every month"). It arrives in their bell and as a push notification on every OpenVibe site. Never say you will remind them without calling this. If it answers with an error asking when, ask them.',++ { text: { type: 'string', description: 'What and when, in their words, without "remind me to": "stretch every 2 hours from 9 to 5"' } }, ['text']);++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_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 QUEUE_STATUS = fn('queue_status', 'What is on this room\'s work queue: what is waiting, being worked on, and recently done or stuck, with who asked.');@@@ -100,7 +108,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] : []),++ ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER] : []),];const clip = (o) => { const s = typeof o === 'string' ? o : JSON.stringify(o); return s.length > RESULT_MAX ? `${s.slice(0, RESULT_MAX - 1)}…` : s; };@@@ -392,8 +400,18 @@ async function setUpAgent(args, ctx) {async function myAgents(_args, ctx) {return await require('./vibe-agents').list(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });}++// Reminders on OpenVibe.Today (plan L48): in the person's own time zone, the one their chat already knows.++async function setReminder(args, ctx) {++ return await require('./vibe-reminders').create(subjectOf(ctx), { text: args.text, timezone: ctx.timezone }, { fetchImpl: ctx.fetchImpl });++}++async function myReminders(_args, ctx) {++ return await require('./vibe-reminders').list(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });++}++async function stopReminder(args, ctx) {++ return await require('./vibe-reminders').stop(subjectOf(ctx), args.id, { 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 };++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 };/*** Runs one tool call and returns both what the model reads (the JSON clipped to RESULT_MAX) and the whole result, so@@@ -404,7 +422,7 @@ 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))-- || ((name === 'set_up_agent' || name === 'my_agents') && !(ownChat(ctx.member, ctx.room) && ctx.user))) {++ || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder'].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 };}nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Reminders from a Vibe chat (plan L48, V1; the owner, 2026-11-01: "use a voice feature to tell it to set a reminder").++ * Vibe sets, lists and stops a person's own reminders on OpenVibe.Today (its set_reminder, my_reminders and++ * stop_reminder tools, server/rooms/ai-tools.js). Chat's own service token (audience openvibe.today, scope++ * today.reminder.write, granted by Network) names the person in X-OV-Subject, so the reminder is theirs: it arrives in++ * their bell and as a push on every OpenVibe site, and they snooze, stop or remove it on openvibe.today/me, where it++ * shows as "Set by Vibe".++ *++ * create(subject, { text, timezone }) → { reminder } | { error } text: what and when, in the person's own words++ * list(subject) → { reminders, manage } | { error }++ * stop(subject, id) → { stopped } | { error }++ * fromRequest(message) → the person's own words when the message plainly asks for a reminder, or null++ */++const config = require('../config');++++const TODAY_SITE = 'https://openvibe.today';++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ID = /^rem_[0-9A-HJKMNP-TV-Z]{26}$/;++++let tokens = null;++function tokenClient() {++ if (tokens) return tokens;++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ // Its own token, apart from Chat's other scopes: asking for a scope Network has not granted would refuse it whole.++ tokens = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.today', scope: 'today.reminder.write',++ });++ return tokens;++}++++/** What Vibe is told about a reminder: its words, its rule in words, when next, and where the person manages it. */++const wire = (r) => ({ id: r.id, title: r.title, when: r.summary, next_at: r.next_at || null, repeats: !!r.repeats, status: r.status });++++async function call(method, path, subject, body, { fetchImpl = globalThis.fetch } = {}) {++ if (!config.oauth.clientSecret) return { error: 'reminders are not available on this server' };++ if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };++ let token;++ try { token = await tokenClient().getToken(); } catch { return { error: 'reminders are not available right now' }; }++ try {++ const res = await fetchImpl(`${config.today.internalUrl}${path}`, {++ method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000),++ });++ const json = res.status === 204 ? {} : await res.json().catch(() => null);++ // Today's own words for a vague time ("Say when: …") go back to Vibe, so it can ask the person.++ if (!res.ok) return { error: (json && (json.error || json.message || json.detail)) ? String(json.error || json.message || json.detail).slice(0, 200) : `OpenVibe.Today answered ${res.status}` };++ return { json };++ } catch { return { error: 'OpenVibe.Today is unreachable' }; }++}++++async function create(subject, { text, timezone } = {}, deps = {}) {++ const words = String(text || '').replace(/\s+/g, ' ').trim();++ if (!words || words.length > 500) return { error: 'say what to remind them of and when, in up to 500 characters' };++ const out = await call('POST', '/api/v1/reminders', subject, { text: words, tz: String(timezone || 'UTC').slice(0, 64), source: 'vibe', url: 'https://openvibe.chat/vibe' }, deps);++ return out.error ? out : { reminder: wire(out.json), manage: `${TODAY_SITE}/me#reminders` };++}++++async function list(subject, deps = {}) {++ const out = await call('GET', '/api/v1/reminders', subject, null, deps);++ if (out.error) return out;++ const all = Array.isArray(out.json && out.json.reminders) ? out.json.reminders : [];++ return { reminders: all.filter((r) => r.status === 'active').slice(0, 30).map(wire), manage: `${TODAY_SITE}/me#reminders` };++}++++async function stop(subject, id, deps = {}) {++ if (!ID.test(String(id || ''))) return { error: 'that is not a reminder id (rem_…); list them with my_reminders' };++ const out = await call('POST', `/api/v1/reminders/${encodeURIComponent(id)}/done`, subject, {}, deps);++ return out.error ? out : { stopped: wire(out.json) };++}++++// "remind me to stretch every 2 hours", "set a reminder for 3pm to call the bank", "can you remind me tomorrow…"++const REQUEST = /^(?:hey\s+vibe[,!]?\s*)?(?:(?:can|could|would|will)\s+you\s+|please\s+)?(?:remind\s+me\b|set\s+(?:me\s+)?(?:a\s+|an?\s+)?reminder\b)/i;++/** The person's words when they plainly ask for a reminder (Vibe then sets it even when the model only promised to). */++function fromRequest(message) {++ const flat = String(message || '').replace(/\s+/g, ' ').trim();++ if (!flat || flat.length > 400 || !REQUEST.test(flat)) return null;++ return flat.replace(/\?+$/, '').trim();++}++++function _setTokens(t) { tokens = t; }++++module.exports = { create, list, stop, fromRequest, _setTokens };nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * Reminders from a Vibe chat (plan L48, V1; server/rooms/vibe-reminders.js and the set_reminder, my_reminders and++ * stop_reminder tools): only Vibe in its person's own chat has them; set_reminder sends the person's words and time++ * zone to OpenVibe.Today as that person (Chat's own today.reminder.write token, X-OV-Subject) and returns what Today++ * understood; Today's "Say when" reaches the model to ask; a plain "remind me …" is recognised for the net in++ * ai-members.js even when the model only promised it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++let aiTools, reminders;++++const t = suite('vibe reminders');++let h, ann;++const sent = [];++let todayAnswer = null;++const todayFetch = async (url, o = {}) => {++ const call = { url: String(url), method: o.method || 'GET', subject: o.headers['X-OV-Subject'], auth: o.headers.Authorization, body: o.body ? JSON.parse(o.body) : null };++ sent.push(call);++ const { status, body } = todayAnswer(call);++ return { ok: status < 300, status, json: async () => body };++};++const REM = { id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ0', title: 'Stretch', summary: 'Every 2 hours, 9 am–5 pm', next_at: '2026-11-02T15:00:00.000Z', repeats: true, status: 'active' };++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: 'America/Chicago', ...ctx }));++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ aiTools = require('../server/rooms/ai-tools');++ reminders = require('../server/rooms/vibe-reminders');++ reminders._setTokens({ getToken: async () => 'chat-today-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 the tools', async () => {++ const names = (m, r) => aiTools.definitions(m, r).map((d) => d.function.name);++ ['set_reminder', 'my_reminders', 'stop_reminder'].forEach((n) => assert.ok(names(vibeMember, ownChat).includes(n), n));++ assert.ok(!names(vibeMember, { id: 2, assistant: 0 }).includes('set_reminder'), 'Vibe added to a room: never');++ assert.ok(!names({ handle: 'dealer' }, ownChat).includes('set_reminder'), 'another AI member: never');++ assert.match((await run('set_reminder', { text: 'x at 3pm' }, { room: { id: 2, assistant: 0 } })).error, /no tool called set_reminder/);++});++++t('set_reminder sends the person\'s words and time zone to Today as them, and returns what Today understood', async () => {++ todayAnswer = () => ({ status: 201, body: { ...REM, said: 'stretch every 2 hours from 9 to 5', source: 'vibe' } });++ const out = await run('set_reminder', { text: 'stretch every 2 hours from 9 to 5' });++ const call = sent[sent.length - 1];++ assert.strictEqual(call.method, 'POST');++ assert.match(call.url, /\/api\/v1\/reminders$/);++ assert.strictEqual(call.subject, h.live.users.get(ann.id).subject_id, 'X-OV-Subject is the person');++ assert.strictEqual(call.auth, 'Bearer chat-today-token');++ assert.deepStrictEqual(call.body, { text: 'stretch every 2 hours from 9 to 5', tz: 'America/Chicago', source: 'vibe', url: 'https://openvibe.chat/vibe' });++ assert.deepStrictEqual(out.reminder, { id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', next_at: REM.next_at, repeats: true, status: 'active' });++ assert.strictEqual(out.manage, 'https://openvibe.today/me#reminders');++});++++t('Today\'s "Say when" reaches the model, so it can ask', async () => {++ todayAnswer = () => ({ status: 422, body: { error: 'Say when: "at 3pm", "tomorrow morning", "every day at 9" or "in 20 minutes".' } });++ const out = await run('set_reminder', { text: 'water the plants' });++ assert.match(out.error, /^Say when/);++});++++t('my_reminders lists the active ones; stop_reminder stops one by its id', async () => {++ todayAnswer = (c) => (c.method === 'GET' ? { status: 200, body: { reminders: [REM, { ...REM, id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ1', status: 'done' }] } } : { status: 200, body: { ...REM, status: 'done' } });++ const list = await run('my_reminders', {});++ assert.deepStrictEqual(list.reminders.map((r) => r.id), [REM.id], 'only the active ones');++ const stopped = await run('stop_reminder', { id: REM.id });++ assert.match(sent[sent.length - 1].url, new RegExp(`/api/v1/reminders/${REM.id}/done$`));++ assert.strictEqual(stopped.stopped.status, 'done');++ assert.match((await run('stop_reminder', { id: '../../etc' })).error, /not a reminder id/);++});++++t('a plain "remind me …" is recognised; a question about reminders is not', async () => {++ assert.strictEqual(reminders.fromRequest('Remind me to stretch every 2 hours from 9 to 5'), 'Remind me to stretch every 2 hours from 9 to 5');++ assert.strictEqual(reminders.fromRequest('can you remind me to call mom on Sunday at 6pm?'), 'can you remind me to call mom on Sunday at 6pm');++ assert.ok(reminders.fromRequest('hey vibe, set a reminder for 3pm to call the bank'));++ assert.strictEqual(reminders.fromRequest('what reminders do I have?'), null);++ assert.strictEqual(reminders.fromRequest('that reminds me of a song'), null);++});++++t('done', async () => { await h.close(); });
@@ -81,6 +81,8 @@ module.exports = {
@@ -81,6 +81,8 @@ module.exports = {
// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
// tasks for the person, with Chat's own token.
// tasks for the person, with Chat's own token.
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
+ // OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.
+ today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
web: {
web: {
@@ -414,6 +414,14 @@ async function afterPost(room, message, user, deps = {}) {
@@ -414,6 +414,14 @@ async function afterPost(room, message, user, deps = {}) {
const note = vibeMemory.fromRequest(message.message);
const note = vibeMemory.fromRequest(message.message);
if (note && (await vibeMemory.add(user, note, { source: 'vibe', roomId: room.id }).catch(() => ({}))).note && steps) require('./how').step(steps, 'remember');
if (note && (await vibeMemory.add(user, note, { source: 'vibe', roomId: room.id }).catch(() => ({}))).note && steps) require('./how').step(steps, 'remember');
}
}
+ // A plain "remind me …" is set even when the model only promised it (small models often do); the answer says so.
+ if (vibe && text && Number(room.owner_id) === Number(user.id) && !called.has('set_reminder')) {
+ const words = require('./vibe-reminders').fromRequest(message.message);
+ if (words) {
+ const set = await require('./vibe-reminders').create(user.subject_id || null, { text: words, timezone: deps.timezone || null }).catch(() => ({}));
+ if (set && set.reminder) text = `${text}\n\n⏰ Reminder set: **${set.reminder.title}**, ${String(set.reminder.when).replace(/^./, (c) => c.toLowerCase())}. Snooze or stop it on [openvibe.today](https://openvibe.today/me#reminders).`;
+ }
+ }
if (!text) {
if (!text) {
const out = await aiClient.structured('chat.member.reply', {
const out = await aiClient.structured('chat.member.reply', {
ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),
ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),
@@ -21,6 +21,9 @@
@@ -21,6 +21,9 @@
* queue_status in a workspace room: what is queued, running and recently done
* queue_status in a workspace room: what is queued, running and recently done
* remember / forget Vibe in a person's own chat with it, while their memory is on (server/rooms/vibe-memory.js):
* remember / forget Vibe in a person's own chat with it, while their memory is on (server/rooms/vibe-memory.js):
* keep a short note across their chats, or delete one by its id
* keep a short note across their chats, or delete one by its id
+ * set_reminder Vibe in a person's own chat: a reminder on OpenVibe.Today from their own words ("stretch every 2
+ * hours from 9 to 5"), sent to their bell and push on every site (server/rooms/vibe-reminders.js);
+ * my_reminders lists them and stop_reminder stops one
* set_up_agent Vibe in a person's own chat: one of their OpenVibe Agents for recurring work, daily or weekly, once
* set_up_agent Vibe in a person's own chat: one of their OpenVibe Agents for recurring work, daily or weekly, once
* they agreed (server/rooms/vibe-agents.js); my_agents lists the ones they have
* they agreed (server/rooms/vibe-agents.js); my_agents lists the ones they have
*
*
@@ -85,6 +88,11 @@ const SET_UP_AGENT = fn('set_up_agent', 'Set up recurring work for this person a
@@ -85,6 +88,11 @@ const SET_UP_AGENT = fn('set_up_agent', 'Set up recurring work for this person a
time_utc: { type: 'string', description: 'When it runs, HH:MM in UTC' },
time_utc: { type: 'string', description: 'When it runs, HH:MM in UTC' },
weekday: { type: 'integer', minimum: 0, maximum: 6, description: 'For weekly: 0 Sunday to 6 Saturday' },
weekday: { type: 'integer', minimum: 0, maximum: 6, description: 'For weekly: 0 Sunday to 6 Saturday' },
}, ['name', 'instructions', 'schedule', 'time_utc']);
}, ['name', 'instructions', 'schedule', 'time_utc']);
+const SET_REMINDER = fn('set_reminder', 'Set a reminder for this person on OpenVibe.Today, from what they said: what to remind them of and when, in plain words ("stretch every 2 hours from 9 to 5", "take my meds every day at 9am and 9pm", "call mom on Sunday at 6pm", "check the oven in 20 minutes", "pay rent on the 1st of every month"). It arrives in their bell and as a push notification on every OpenVibe site. Never say you will remind them without calling this. If it answers with an error asking when, ask them.',
+ { text: { type: 'string', description: 'What and when, in their words, without "remind me to": "stretch every 2 hours from 9 to 5"' } }, ['text']);
+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_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 QUEUE_STATUS = fn('queue_status', 'What is on this room\'s work queue: what is waiting, being worked on, and recently done or stuck, with who asked.');
const QUEUE_STATUS = fn('queue_status', 'What is on this room\'s work queue: what is waiting, being worked on, and recently done or stuck, with who asked.');
@@ -100,7 +108,7 @@ const definitions = (member, room = null, { memory = false } = {}) => [
@@ -100,7 +108,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] : []),
+ ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER] : []),
];
];
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; };
@@ -392,8 +400,18 @@ async function setUpAgent(args, ctx) {
@@ -392,8 +400,18 @@ async function setUpAgent(args, ctx) {
async function myAgents(_args, ctx) {
async function myAgents(_args, ctx) {
return await require('./vibe-agents').list(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });
return await require('./vibe-agents').list(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });
}
}
+// Reminders on OpenVibe.Today (plan L48): in the person's own time zone, the one their chat already knows.
+async function setReminder(args, ctx) {
+ return await require('./vibe-reminders').create(subjectOf(ctx), { text: args.text, timezone: ctx.timezone }, { fetchImpl: ctx.fetchImpl });
+}
+async function myReminders(_args, ctx) {
+ return await require('./vibe-reminders').list(subjectOf(ctx), { fetchImpl: ctx.fetchImpl });
+}
+async function stopReminder(args, ctx) {
+ return await require('./vibe-reminders').stop(subjectOf(ctx), args.id, { 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 };
+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 };
/**
/**
* 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
@@ -404,7 +422,7 @@ async function runWithResult(name, raw, ctx = {}) {
@@ -404,7 +422,7 @@ 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))
- || ((name === 'set_up_agent' || name === 'my_agents') && !(ownChat(ctx.member, ctx.room) && ctx.user))) {
+ || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder'].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 };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Reminders from a Vibe chat (plan L48, V1; the owner, 2026-11-01: "use a voice feature to tell it to set a reminder").
+ * Vibe sets, lists and stops a person's own reminders on OpenVibe.Today (its set_reminder, my_reminders and
+ * stop_reminder tools, server/rooms/ai-tools.js). Chat's own service token (audience openvibe.today, scope
+ * today.reminder.write, granted by Network) names the person in X-OV-Subject, so the reminder is theirs: it arrives in
+ * their bell and as a push on every OpenVibe site, and they snooze, stop or remove it on openvibe.today/me, where it
+ * shows as "Set by Vibe".
+ *
+ * create(subject, { text, timezone }) → { reminder } | { error } text: what and when, in the person's own words
+ * list(subject) → { reminders, manage } | { error }
+ * stop(subject, id) → { stopped } | { error }
+ * fromRequest(message) → the person's own words when the message plainly asks for a reminder, or null
+ */
+const config = require('../config');
+
+const TODAY_SITE = 'https://openvibe.today';
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ID = /^rem_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+let tokens = null;
+function tokenClient() {
+ if (tokens) return tokens;
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ // Its own token, apart from Chat's other scopes: asking for a scope Network has not granted would refuse it whole.
+ tokens = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.today', scope: 'today.reminder.write',
+ });
+ return tokens;
+}
+
+/** What Vibe is told about a reminder: its words, its rule in words, when next, and where the person manages it. */
+const wire = (r) => ({ id: r.id, title: r.title, when: r.summary, next_at: r.next_at || null, repeats: !!r.repeats, status: r.status });
+
+async function call(method, path, subject, body, { fetchImpl = globalThis.fetch } = {}) {
+ if (!config.oauth.clientSecret) return { error: 'reminders are not available on this server' };
+ if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };
+ let token;
+ try { token = await tokenClient().getToken(); } catch { return { error: 'reminders are not available right now' }; }
+ try {
+ const res = await fetchImpl(`${config.today.internalUrl}${path}`, {
+ method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000),
+ });
+ const json = res.status === 204 ? {} : await res.json().catch(() => null);
+ // Today's own words for a vague time ("Say when: …") go back to Vibe, so it can ask the person.
+ if (!res.ok) return { error: (json && (json.error || json.message || json.detail)) ? String(json.error || json.message || json.detail).slice(0, 200) : `OpenVibe.Today answered ${res.status}` };
+ return { json };
+ } catch { return { error: 'OpenVibe.Today is unreachable' }; }
+}
+
+async function create(subject, { text, timezone } = {}, deps = {}) {
+ const words = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!words || words.length > 500) return { error: 'say what to remind them of and when, in up to 500 characters' };
+ const out = await call('POST', '/api/v1/reminders', subject, { text: words, tz: String(timezone || 'UTC').slice(0, 64), source: 'vibe', url: 'https://openvibe.chat/vibe' }, deps);
+ return out.error ? out : { reminder: wire(out.json), manage: `${TODAY_SITE}/me#reminders` };
+}
+
+async function list(subject, deps = {}) {
+ const out = await call('GET', '/api/v1/reminders', subject, null, deps);
+ if (out.error) return out;
+ const all = Array.isArray(out.json && out.json.reminders) ? out.json.reminders : [];
+ return { reminders: all.filter((r) => r.status === 'active').slice(0, 30).map(wire), manage: `${TODAY_SITE}/me#reminders` };
+}
+
+async function stop(subject, id, deps = {}) {
+ if (!ID.test(String(id || ''))) return { error: 'that is not a reminder id (rem_…); list them with my_reminders' };
+ const out = await call('POST', `/api/v1/reminders/${encodeURIComponent(id)}/done`, subject, {}, deps);
+ return out.error ? out : { stopped: wire(out.json) };
+}
+
+// "remind me to stretch every 2 hours", "set a reminder for 3pm to call the bank", "can you remind me tomorrow…"
+const REQUEST = /^(?:hey\s+vibe[,!]?\s*)?(?:(?:can|could|would|will)\s+you\s+|please\s+)?(?:remind\s+me\b|set\s+(?:me\s+)?(?:a\s+|an?\s+)?reminder\b)/i;
+/** The person's words when they plainly ask for a reminder (Vibe then sets it even when the model only promised to). */
+function fromRequest(message) {
+ const flat = String(message || '').replace(/\s+/g, ' ').trim();
+ if (!flat || flat.length > 400 || !REQUEST.test(flat)) return null;
+ return flat.replace(/\?+$/, '').trim();
+}
+
+function _setTokens(t) { tokens = t; }
+
+module.exports = { create, list, stop, fromRequest, _setTokens };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * Reminders from a Vibe chat (plan L48, V1; server/rooms/vibe-reminders.js and the set_reminder, my_reminders and
+ * stop_reminder tools): only Vibe in its person's own chat has them; set_reminder sends the person's words and time
+ * zone to OpenVibe.Today as that person (Chat's own today.reminder.write token, X-OV-Subject) and returns what Today
+ * understood; Today's "Say when" reaches the model to ask; a plain "remind me …" is recognised for the net in
+ * ai-members.js even when the model only promised it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+let aiTools, reminders;
+
+const t = suite('vibe reminders');
+let h, ann;
+const sent = [];
+let todayAnswer = null;
+const todayFetch = async (url, o = {}) => {
+ const call = { url: String(url), method: o.method || 'GET', subject: o.headers['X-OV-Subject'], auth: o.headers.Authorization, body: o.body ? JSON.parse(o.body) : null };
+ sent.push(call);
+ const { status, body } = todayAnswer(call);
+ return { ok: status < 300, status, json: async () => body };
+};
+const REM = { id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ0', title: 'Stretch', summary: 'Every 2 hours, 9 am–5 pm', next_at: '2026-11-02T15:00:00.000Z', repeats: true, status: 'active' };
+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: 'America/Chicago', ...ctx }));
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ aiTools = require('../server/rooms/ai-tools');
+ reminders = require('../server/rooms/vibe-reminders');
+ reminders._setTokens({ getToken: async () => 'chat-today-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 the tools', async () => {
+ const names = (m, r) => aiTools.definitions(m, r).map((d) => d.function.name);
+ ['set_reminder', 'my_reminders', 'stop_reminder'].forEach((n) => assert.ok(names(vibeMember, ownChat).includes(n), n));
+ assert.ok(!names(vibeMember, { id: 2, assistant: 0 }).includes('set_reminder'), 'Vibe added to a room: never');
+ assert.ok(!names({ handle: 'dealer' }, ownChat).includes('set_reminder'), 'another AI member: never');
+ assert.match((await run('set_reminder', { text: 'x at 3pm' }, { room: { id: 2, assistant: 0 } })).error, /no tool called set_reminder/);
+});
+
+t('set_reminder sends the person\'s words and time zone to Today as them, and returns what Today understood', async () => {
+ todayAnswer = () => ({ status: 201, body: { ...REM, said: 'stretch every 2 hours from 9 to 5', source: 'vibe' } });
+ const out = await run('set_reminder', { text: 'stretch every 2 hours from 9 to 5' });
+ const call = sent[sent.length - 1];
+ assert.strictEqual(call.method, 'POST');
+ assert.match(call.url, /\/api\/v1\/reminders$/);
+ assert.strictEqual(call.subject, h.live.users.get(ann.id).subject_id, 'X-OV-Subject is the person');
+ assert.strictEqual(call.auth, 'Bearer chat-today-token');
+ assert.deepStrictEqual(call.body, { text: 'stretch every 2 hours from 9 to 5', tz: 'America/Chicago', source: 'vibe', url: 'https://openvibe.chat/vibe' });
+ assert.deepStrictEqual(out.reminder, { id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', next_at: REM.next_at, repeats: true, status: 'active' });
+ assert.strictEqual(out.manage, 'https://openvibe.today/me#reminders');
+});
+
+t('Today\'s "Say when" reaches the model, so it can ask', async () => {
+ todayAnswer = () => ({ status: 422, body: { error: 'Say when: "at 3pm", "tomorrow morning", "every day at 9" or "in 20 minutes".' } });
+ const out = await run('set_reminder', { text: 'water the plants' });
+ assert.match(out.error, /^Say when/);
+});
+
+t('my_reminders lists the active ones; stop_reminder stops one by its id', async () => {
+ todayAnswer = (c) => (c.method === 'GET' ? { status: 200, body: { reminders: [REM, { ...REM, id: 'rem_01JABCDEFGHJKMNPQRSTVWXYZ1', status: 'done' }] } } : { status: 200, body: { ...REM, status: 'done' } });
+ const list = await run('my_reminders', {});
+ assert.deepStrictEqual(list.reminders.map((r) => r.id), [REM.id], 'only the active ones');
+ const stopped = await run('stop_reminder', { id: REM.id });
+ assert.match(sent[sent.length - 1].url, new RegExp(`/api/v1/reminders/${REM.id}/done$`));
+ assert.strictEqual(stopped.stopped.status, 'done');
+ assert.match((await run('stop_reminder', { id: '../../etc' })).error, /not a reminder id/);
+});
+
+t('a plain "remind me …" is recognised; a question about reminders is not', async () => {
+ assert.strictEqual(reminders.fromRequest('Remind me to stretch every 2 hours from 9 to 5'), 'Remind me to stretch every 2 hours from 9 to 5');
+ assert.strictEqual(reminders.fromRequest('can you remind me to call mom on Sunday at 6pm?'), 'can you remind me to call mom on Sunday at 6pm');
+ assert.ok(reminders.fromRequest('hey vibe, set a reminder for 3pm to call the bank'));
+ assert.strictEqual(reminders.fromRequest('what reminders do I have?'), null);
+ assert.strictEqual(reminders.fromRequest('that reminds me of a song'), null);
+});
+
+t('done', async () => { await h.close(); });