openvibers/openvibe.chat Commits
Vibe can start the person's Dock timer (#291)
Changes
@@@ -422,6 +422,19 @@ async function afterPost(room, message, user, deps = {}) {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).`;}}++ // A plain "start a 25 minute timer" is set even when the model only promised it (small models often do);++ // the answer says so, and only when the timer really is set.++ if (vibe && text && Number(room.owner_id) === Number(user.id) && !called.has('start_timer') && !called.has('stop_timer')) {++ const want = require('./vibe-timer').fromRequest(message.message);++ if (want) {++ const timer = require('./vibe-timer');++ const set = await (want.stop++ ? timer.stop(user.subject_id || null).catch(() => ({}))++ : timer.start(user.subject_id || null, want).catch(() => ({})));++ if (set && set.timer) text = `${text}\n\n⏱ ${set.said}`;++ else if (set && set.stopped) text = `${text}\n\n⏱ ${set.said}`;++ }++ }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),@@@ -26,6 +26,8 @@* 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++ * start_timer Vibe in a person's own chat: the timer in their Dock, counting down on every OpenVibe site++ * ("start a 25 minute timer", "a pomodoro") (server/rooms/vibe-timer.js); stop_timer stops it** definitions(member, room, { memory }) → the OpenAI-style tool list for this member* run(name, args, ctx) → the result as a string (JSON, at most RESULT_MAX characters), never throws@@@ -94,6 +96,13 @@ const MY_REMINDERS = fn('my_reminders', 'This person\'s active reminders on Openconst 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 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.',++ {++ minutes: { type: 'integer', minimum: 1, maximum: 180, description: 'How long it runs, in minutes (1 to 180)' },++ label: { type: 'string', maxLength: 60, description: 'What it is for, in their words (optional)' },++ focus_rounds: { type: 'integer', minimum: 1, maximum: 12, description: 'How many focus rounds in all, with a 5-minute break after each (optional; leave it out for a plain timer)' },++ }, ['minutes']);++const STOP_TIMER = fn('stop_timer', 'Stop this person\'s timer in their Dock, when they ask to stop, cancel or clear it.');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 isVibe = (member) => !!member && member.handle === 'vibe';@@@ -108,7 +117,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] : []),++ ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER, 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; };@@@ -410,8 +419,20 @@ async function myReminders(_args, ctx) {async function stopReminder(args, ctx) {return await require('./vibe-reminders').stop(subjectOf(ctx), args.id, { fetchImpl: ctx.fetchImpl });}++// The timer in the person's Dock (plan L48 R2): the Network holds it, so it counts down on every site.++async function startTimer(args, ctx) {++ const rounds = Number(args.focus_rounds);++ return await require('./vibe-timer').start(subjectOf(ctx), {++ minutes: args.minutes, label: args.label || null,++ kind: Number.isFinite(rounds) && rounds >= 1 ? 'focus' : 'timer',++ rounds: Number.isFinite(rounds) && rounds >= 1 ? rounds : null,++ }, { 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 };++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 };/*** Runs one tool call and returns both what the model reads (the JSON clipped to RESULT_MAX) and the whole result, so@@@ -422,7 +443,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))-- || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder'].includes(name) && !(ownChat(ctx.member, ctx.room) && 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))) {const error = { error: `no tool called ${String(name).slice(0, 64)}` };return { text: clip(error), result: error };}nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++/**++ * The Dock's timer, set from a Vibe chat (plan L48 R2; the owner, 2026-11-01: "Vibe, start a 25-minute focus timer").++ * Vibe starts and stops the timer in the person's Dock (its start_timer and stop_timer tools,++ * server/rooms/ai-tools.js) with Chat's own service token (audience openvibe.network, scope++ * network.dock.timer.write, granted by Network), so the timer counts down in the Dock's pill on every OpenVibe site++ * and rings at its end. The person is named with X-OV-Subject, so the timer is theirs.++ *++ * start(subject, { minutes, label?, kind?, rounds? }) → { timer, said } | { error }++ * stop(subject) → { stopped, said } | { error }++ * fromRequest(message) → what they plainly asked for, or null++ */++const config = require('../config');++++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_MINUTES = 180;++++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.network', scope: 'network.dock.timer.write',++ });++ return tokens;++}++++/** What Vibe is told about the timer: the object the Dock reads, and the words to say about it. */++const wire = (t) => ({++ kind: t.kind, started_at: t.started_at, ends_at: t.ends_at,++ ...(t.label ? { label: t.label } : {}), ...(t.round ? { round: t.round } : {}), ...(t.rounds ? { rounds: t.rounds } : {}),++ ...(t.focus_min ? { focus_min: t.focus_min } : {}), ...(t.break_min ? { break_min: t.break_min } : {}),++});++++/** What Vibe says when the timer is set: how long it runs, and where it counts down. */++function sayOf(timer) {++ const minutes = Math.max(1, Math.round((Date.parse(timer.ends_at) - Date.parse(timer.started_at)) / 60000));++ const kind = timer.kind === 'focus' ? ', focus' : '';++ return `Timer set: ${minutes} min${kind}. It's in the Dock on every site.`;++}++++async function call(subject, body, { fetchImpl = globalThis.fetch } = {}) {++ if (!config.oauth.clientSecret) return { error: 'the timer is 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: 'the timer is not available right now' }; }++ try {++ const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/people/${encodeURIComponent(subject)}/dock/timer`, {++ method: 'PUT',++ headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', 'Content-Type': 'application/json' },++ body: JSON.stringify(body), signal: AbortSignal.timeout(10_000),++ });++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const detail = json && (json.detail || json.error || json.message);++ return { error: detail ? String(detail).slice(0, 200) : `OpenVibe.Network answered ${res.status}` };++ }++ return { json };++ } catch { return { error: 'OpenVibe.Network is unreachable' }; }++}++++/**++ * Start the person's timer.++ * minutes how long it runs, 1 to 180++ * kind 'timer' (a plain one) or 'focus' (a block that alternates a round and a break by itself)++ * label what it is for, in their words++ * rounds how many focus rounds in all (a focus timer only; the Dock's own default is 4)++ */++async function start(subject, { minutes, label = null, kind = null, rounds = null } = {}, deps = {}) {++ const m = Math.round(Number(minutes));++ if (!Number.isFinite(m) || m < 1 || m > MAX_MINUTES) return { error: 'the timer runs from 1 to 180 minutes' };++ const body = { minutes: m };++ if (kind === 'focus') body.kind = 'focus';++ if (label) body.label = String(label).slice(0, 60);++ if (kind === 'focus') body.rounds = Math.min(12, Math.max(1, Math.round(Number(rounds) || 4)));++ const out = await call(subject, body, deps);++ if (out.error) return out;++ const timer = out.json && out.json.state && out.json.state.timer;++ if (!timer) return { error: 'the timer was not set' };++ return { timer: wire(timer), said: sayOf(timer) };++}++++/** Stop the person's timer. */++async function stop(subject, deps = {}) {++ const out = await call(subject, { clear: true }, deps);++ if (out.error) return out;++ return { stopped: true, said: 'Timer stopped.' };++}++++// "a 25 minute timer", "set a timer for 10 minutes", "focus timer", "pomodoro", "stop the timer".++const STOP = /\b(?:stop|cancel|end|clear|remove|delete)\b[^.?!]{0,24}\btimers?\b/i;++const ACTION = /\b(?:start|set|begin|put|give|launch|run)\b/i;++const MINUTES = /\b(\d{1,3})\s*(?:minutes?|mins?|m)\b/i;++const FOCUS = /\b(?:pomodoro|focus|deep\s+work)\b/i;++++/**++ * The person's words when they plainly ask for a timer (Vibe then sets it even when the model only promised to).++ * → { minutes, kind } to start one, { stop: true } to stop it, or null when they did not ask.++ */++function fromRequest(message) {++ const flat = String(message || '').replace(/\s+/g, ' ').trim();++ if (!flat || flat.length > 400) return null;++ if (STOP.test(flat)) return { stop: true };++ const focus = FOCUS.test(flat);++ if (!/\btimers?\b/.test(flat) && !focus) return null;++ const action = ACTION.test(flat);++ const said = MINUTES.exec(flat);++ if (!action && !focus && !said) return null; // a question about timers ("how does the timer work?") is not a request++ if (said) {++ const m = Number(said[1]);++ if (!Number.isFinite(m) || m < 1 || m > MAX_MINUTES) return null; // out of range: let Vibe ask++ // "how long is a 25 minute timer?" is a question, not a request for one.++ if (!action && /\?\s*$/.test(flat)) return null;++ return { minutes: m, kind: focus ? 'focus' : 'timer' };++ }++ // "a pomodoro" or "a focus timer" is 25 minutes of focus, the way the Dock's own focus block is.++ if (focus) return { minutes: 25, kind: 'focus' };++ // "start a timer" with no length: 25 minutes, the Dock's own focus length.++ return { minutes: 25, kind: 'timer' };++}++++function _setTokens(t) { tokens = t; }++++module.exports = { start, stop, fromRequest, sayOf, wire, _setTokens };nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * The deterministic net for the Dock's timer (plan L48 R2; server/rooms/ai-members.js afterPost): a plain "start a++ * 25 minute timer" is set even when the model only promised it (small models often do), and the answer says what was++ * set — but only when the timer really is set. When the Network refuses, the answer says nothing about a timer.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe timer net');++let h, ann, ai, config, aiClient;++const RUN = (content) => ({ id: 'run_1', status: 'succeeded', output: { choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] } });++++t('boot', async () => {++ // The model answers in words and calls no tool: the case the net is there for.++ ai = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' });++ const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);++ send('delta', { text: 'Sure, I will start that timer for you.' });++ send('run', { run: RUN('Sure, I will start that timer for you.') });++ res.end();++ });++ });++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ config = require('../server/config');++ config.ai.internalUrl = `http://127.0.0.1:${ai.address().port}`;++ aiClient = require('../server/ai/client');++ aiClient._reset();++ aiClient._setTokens({ getToken: async () => 'chat-ai-token' });++ timer = require('../server/rooms/vibe-timer'); // after the boot: the config reads the environment++ realStart = timer.start;++ realStop = timer.stop;++ ann = h.addUser('ann', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++});++++let timer = null;++const asked = [];++const TIMER = { kind: 'focus', label: null, started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:25:00.000Z', round: 1, rounds: 4, focus_min: 25, break_min: 5 };++let realStart = null;++let realStop = null;++++async function ask(text) {++ const vibe = require('../server/rooms/vibe');++ const rooms = require('../server/rooms/rooms');++ const user = h.live.users.get(ann.id);++ const room = await vibe.createRoom(user, text);++ const message = await rooms.post(room, user, text, { ai: false });++ const frames = [];++ const reply = await require('../server/rooms/ai-members').afterPost(room, message, user, { broadcast: (f) => frames.push(f) });++ return { frames, reply };++}++++t('a plain "start a 25 minute timer" is set even when the model only promised it, and the answer says so', async () => {++ asked.length = 0;++ timer.start = async (subject, want) => { asked.push({ subject, want }); return { timer: TIMER, said: timer.sayOf(TIMER) }; };++ try {++ const { reply } = await ask('start a 25 minute timer');++ assert.strictEqual(asked.length, 1, 'the timer was set');++ assert.strictEqual(asked[0].subject, h.live.users.get(ann.id).subject_id, 'it is the person\'s own timer');++ assert.deepStrictEqual(asked[0].want, { minutes: 25, kind: 'timer' });++ assert.match(reply.message, /Timer set: 25 min/);++ assert.match(reply.message, /It's in the Dock on every site\./);++ } finally { timer.start = realStart; }++});++++t('when the Network refuses, the answer does not say a timer was set', async () => {++ asked.length = 0;++ timer.start = async (subject, want) => { asked.push({ subject, want }); return { error: 'the timer is not available right now' }; };++ try {++ const { reply } = await ask('start a 25 minute timer');++ assert.strictEqual(asked.length, 1, 'it was tried');++ assert.ok(!/Timer set/.test(reply.message), `the answer says nothing about a timer: ${reply.message}`);++ } finally { timer.start = realStart; }++});++++t('"stop the timer" stops it, and says so', async () => {++ asked.length = 0;++ timer.stop = async (subject) => { asked.push({ subject }); return { stopped: true, said: 'Timer stopped.' }; };++ try {++ const { reply } = await ask('stop the timer');++ assert.strictEqual(asked.length, 1);++ assert.match(reply.message, /Timer stopped\./);++ } finally { timer.stop = realStop; }++});++++t.run(async () => { aiClient._reset(); if (h && h.close) await h.close(); await new Promise((r) => ai.close(r)); });nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * The Dock's timer from a Vibe chat (plan L48 R2; server/rooms/vibe-timer.js and the start_timer and stop_timer++ * tools): only Vibe in its person's own chat has them; start_timer asks the Network to set that person's Dock timer++ * (Chat's own network.dock.timer.write token, X-OV-Subject) and answers with the timer the Dock will count down;++ * stop_timer clears it; a plain "a 25 minute timer" 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, timer;++++const t = suite('vibe timer');++let h, ann;++const sent = [];++let netAnswer = null;++const netFetch = 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 } = netAnswer(call);++ return { ok: status < 300, status, json: async () => body };++};++// The timer the Dock reads (openvibe-shared/dock-timer.js start()).++const TIMER = { kind: 'focus', label: 'Write the post', started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:25:00.000Z', round: 1, rounds: 4, focus_min: 25, break_min: 5 };++const PLAIN = { kind: 'timer', started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:10:00.000Z' };++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: netFetch, ...ctx }));++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ aiTools = require('../server/rooms/ai-tools');++ timer = require('../server/rooms/vibe-timer');++ timer._setTokens({ getToken: async () => 'chat-network-token' });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++ netAnswer = () => ({ status: 200, body: { state: { timer: TIMER }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });++});++++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);++ ['start_timer', 'stop_timer'].forEach((n) => assert.ok(names(vibeMember, ownChat).includes(n), n));++ assert.ok(!names(vibeMember, { id: 2, assistant: 0 }).includes('start_timer'), 'Vibe added to a room: never');++ assert.ok(!names({ handle: 'dealer' }, ownChat).includes('start_timer'), 'another AI member: never');++ assert.match((await run('start_timer', { minutes: 25 }, { room: { id: 2, assistant: 0 } })).error, /no tool called start_timer/);++});++++t('start_timer asks the Network to set that person\'s timer, and answers with the timer the Dock reads', async () => {++ const out = await run('start_timer', { minutes: 25, label: 'Write the post', focus_rounds: 4 });++ const call = sent[sent.length - 1];++ assert.strictEqual(call.method, 'PUT');++ assert.match(call.url, /\/api\/v1\/people\/usr_[0-9A-HJKMNP-TV-Z]{26}\/dock\/timer$/);++ assert.strictEqual(call.subject, h.live.users.get(ann.id).subject_id, 'X-OV-Subject is the person');++ assert.strictEqual(call.auth, 'Bearer chat-network-token');++ assert.deepStrictEqual(call.body, { minutes: 25, kind: 'focus', label: 'Write the post', rounds: 4 });++ assert.deepStrictEqual(out.timer, TIMER);++ assert.strictEqual(out.said, "Timer set: 25 min, focus. It's in the Dock on every site.");++});++++t('a plain timer has no rounds and no focus', async () => {++ netAnswer = () => ({ status: 200, body: { state: { timer: PLAIN }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });++ const out = await run('start_timer', { minutes: 10 });++ assert.deepStrictEqual(sent[sent.length - 1].body, { minutes: 10 }, 'no kind, no rounds, no label');++ assert.strictEqual(out.timer.kind, 'timer');++ assert.strictEqual(out.said, "Timer set: 10 min. It's in the Dock on every site.");++});++++t('stop_timer clears it', async () => {++ netAnswer = () => ({ status: 200, body: { state: { timer: null }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });++ const out = await run('stop_timer', {});++ assert.deepStrictEqual(sent[sent.length - 1].body, { clear: true });++ assert.strictEqual(out.stopped, true);++ assert.strictEqual(out.said, 'Timer stopped.');++});++++t('a timer outside 1 to 180 minutes never reaches the Network', async () => {++ const before = sent.length;++ assert.match((await run('start_timer', { minutes: 0 })).error, /1 to 180/);++ assert.match((await run('start_timer', { minutes: 200 })).error, /1 to 180/);++ assert.strictEqual(sent.length, before, 'nothing was sent');++});++++t('the Network\'s refusal reaches the model', async () => {++ netAnswer = () => ({ status: 422, body: { code: 'dock.invalid', detail: 'minutes: must be integer' } });++ const out = await run('start_timer', { minutes: 25 });++ assert.match(out.error, /minutes: must be integer/);++ netAnswer = () => ({ status: 403, body: { code: 'capability.denied', detail: 'network.dock.timer.write not granted' } });++ assert.match((await run('start_timer', { minutes: 25 })).error, /not granted/);++});++++t('a plain "a 25 minute timer" is recognised; a question about timers is not', async () => {++ assert.deepStrictEqual(timer.fromRequest('a 25 minute timer'), { minutes: 25, kind: 'timer' });++ assert.deepStrictEqual(timer.fromRequest('Vibe, start a 25 minute focus timer'), { minutes: 25, kind: 'focus' });++ assert.deepStrictEqual(timer.fromRequest('set a timer for 10 minutes'), { minutes: 10, kind: 'timer' });++ assert.deepStrictEqual(timer.fromRequest('focus timer'), { minutes: 25, kind: 'focus' });++ assert.deepStrictEqual(timer.fromRequest('give me a pomodoro'), { minutes: 25, kind: 'focus' });++ assert.deepStrictEqual(timer.fromRequest('stop the timer'), { stop: true });++ assert.deepStrictEqual(timer.fromRequest('can you cancel my timer?'), { stop: true });++ assert.strictEqual(timer.fromRequest('how does the timer work?'), null, 'a question is not a request');++ assert.strictEqual(timer.fromRequest('what is in my dock?'), null);++ assert.strictEqual(timer.fromRequest('start a 200 minute timer'), null, 'out of range: let Vibe ask');++});++++t('done', async () => { await h.close(); });++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -422,6 +422,19 @@ async function afterPost(room, message, user, deps = {}) {
@@ -422,6 +422,19 @@ async function afterPost(room, message, user, deps = {}) {
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 (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).`;
}
}
}
}
+ // A plain "start a 25 minute timer" is set even when the model only promised it (small models often do);
+ // the answer says so, and only when the timer really is set.
+ if (vibe && text && Number(room.owner_id) === Number(user.id) && !called.has('start_timer') && !called.has('stop_timer')) {
+ const want = require('./vibe-timer').fromRequest(message.message);
+ if (want) {
+ const timer = require('./vibe-timer');
+ const set = await (want.stop
+ ? timer.stop(user.subject_id || null).catch(() => ({}))
+ : timer.start(user.subject_id || null, want).catch(() => ({})));
+ if (set && set.timer) text = `${text}\n\n⏱ ${set.said}`;
+ else if (set && set.stopped) text = `${text}\n\n⏱ ${set.said}`;
+ }
+ }
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),
@@ -26,6 +26,8 @@
@@ -26,6 +26,8 @@
* my_reminders lists them and stop_reminder stops one
* 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
+ * start_timer Vibe in a person's own chat: the timer in their Dock, counting down on every OpenVibe site
+ * ("start a 25 minute timer", "a pomodoro") (server/rooms/vibe-timer.js); stop_timer stops it
*
*
* definitions(member, room, { memory }) → the OpenAI-style tool list for this member
* definitions(member, room, { memory }) → the OpenAI-style tool list for this member
* run(name, args, ctx) → the result as a string (JSON, at most RESULT_MAX characters), never throws
* run(name, args, ctx) → the result as a string (JSON, at most RESULT_MAX characters), never throws
@@ -94,6 +96,13 @@ const MY_REMINDERS = fn('my_reminders', 'This person\'s active reminders on Open
@@ -94,6 +96,13 @@ const MY_REMINDERS = fn('my_reminders', 'This person\'s active reminders on Open
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_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.',
+ {
+ minutes: { type: 'integer', minimum: 1, maximum: 180, description: 'How long it runs, in minutes (1 to 180)' },
+ label: { type: 'string', maxLength: 60, description: 'What it is for, in their words (optional)' },
+ focus_rounds: { type: 'integer', minimum: 1, maximum: 12, description: 'How many focus rounds in all, with a 5-minute break after each (optional; leave it out for a plain timer)' },
+ }, ['minutes']);
+const STOP_TIMER = fn('stop_timer', 'Stop this person\'s timer in their Dock, when they ask to stop, cancel or clear it.');
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.');
const isVibe = (member) => !!member && member.handle === 'vibe';
const isVibe = (member) => !!member && member.handle === 'vibe';
@@ -108,7 +117,7 @@ const definitions = (member, room = null, { memory = false } = {}) => [
@@ -108,7 +117,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] : []),
+ ...(ownChat(member, room) ? [SET_UP_AGENT, MY_AGENTS, SET_REMINDER, MY_REMINDERS, STOP_REMINDER, 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; };
@@ -410,8 +419,20 @@ async function myReminders(_args, ctx) {
@@ -410,8 +419,20 @@ async function myReminders(_args, ctx) {
async function stopReminder(args, ctx) {
async function stopReminder(args, ctx) {
return await require('./vibe-reminders').stop(subjectOf(ctx), args.id, { fetchImpl: ctx.fetchImpl });
return await require('./vibe-reminders').stop(subjectOf(ctx), args.id, { fetchImpl: ctx.fetchImpl });
}
}
+// The timer in the person's Dock (plan L48 R2): the Network holds it, so it counts down on every site.
+async function startTimer(args, ctx) {
+ const rounds = Number(args.focus_rounds);
+ return await require('./vibe-timer').start(subjectOf(ctx), {
+ minutes: args.minutes, label: args.label || null,
+ kind: Number.isFinite(rounds) && rounds >= 1 ? 'focus' : 'timer',
+ rounds: Number.isFinite(rounds) && rounds >= 1 ? rounds : null,
+ }, { 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 };
+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 };
/**
/**
* 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
@@ -422,7 +443,7 @@ async function runWithResult(name, raw, ctx = {}) {
@@ -422,7 +443,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))
- || (['set_up_agent', 'my_agents', 'set_reminder', 'my_reminders', 'stop_reminder'].includes(name) && !(ownChat(ctx.member, ctx.room) && 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))) {
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,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+/**
+ * The Dock's timer, set from a Vibe chat (plan L48 R2; the owner, 2026-11-01: "Vibe, start a 25-minute focus timer").
+ * Vibe starts and stops the timer in the person's Dock (its start_timer and stop_timer tools,
+ * server/rooms/ai-tools.js) with Chat's own service token (audience openvibe.network, scope
+ * network.dock.timer.write, granted by Network), so the timer counts down in the Dock's pill on every OpenVibe site
+ * and rings at its end. The person is named with X-OV-Subject, so the timer is theirs.
+ *
+ * start(subject, { minutes, label?, kind?, rounds? }) → { timer, said } | { error }
+ * stop(subject) → { stopped, said } | { error }
+ * fromRequest(message) → what they plainly asked for, or null
+ */
+const config = require('../config');
+
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_MINUTES = 180;
+
+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.network', scope: 'network.dock.timer.write',
+ });
+ return tokens;
+}
+
+/** What Vibe is told about the timer: the object the Dock reads, and the words to say about it. */
+const wire = (t) => ({
+ kind: t.kind, started_at: t.started_at, ends_at: t.ends_at,
+ ...(t.label ? { label: t.label } : {}), ...(t.round ? { round: t.round } : {}), ...(t.rounds ? { rounds: t.rounds } : {}),
+ ...(t.focus_min ? { focus_min: t.focus_min } : {}), ...(t.break_min ? { break_min: t.break_min } : {}),
+});
+
+/** What Vibe says when the timer is set: how long it runs, and where it counts down. */
+function sayOf(timer) {
+ const minutes = Math.max(1, Math.round((Date.parse(timer.ends_at) - Date.parse(timer.started_at)) / 60000));
+ const kind = timer.kind === 'focus' ? ', focus' : '';
+ return `Timer set: ${minutes} min${kind}. It's in the Dock on every site.`;
+}
+
+async function call(subject, body, { fetchImpl = globalThis.fetch } = {}) {
+ if (!config.oauth.clientSecret) return { error: 'the timer is 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: 'the timer is not available right now' }; }
+ try {
+ const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/people/${encodeURIComponent(subject)}/dock/timer`, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body), signal: AbortSignal.timeout(10_000),
+ });
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const detail = json && (json.detail || json.error || json.message);
+ return { error: detail ? String(detail).slice(0, 200) : `OpenVibe.Network answered ${res.status}` };
+ }
+ return { json };
+ } catch { return { error: 'OpenVibe.Network is unreachable' }; }
+}
+
+/**
+ * Start the person's timer.
+ * minutes how long it runs, 1 to 180
+ * kind 'timer' (a plain one) or 'focus' (a block that alternates a round and a break by itself)
+ * label what it is for, in their words
+ * rounds how many focus rounds in all (a focus timer only; the Dock's own default is 4)
+ */
+async function start(subject, { minutes, label = null, kind = null, rounds = null } = {}, deps = {}) {
+ const m = Math.round(Number(minutes));
+ if (!Number.isFinite(m) || m < 1 || m > MAX_MINUTES) return { error: 'the timer runs from 1 to 180 minutes' };
+ const body = { minutes: m };
+ if (kind === 'focus') body.kind = 'focus';
+ if (label) body.label = String(label).slice(0, 60);
+ if (kind === 'focus') body.rounds = Math.min(12, Math.max(1, Math.round(Number(rounds) || 4)));
+ const out = await call(subject, body, deps);
+ if (out.error) return out;
+ const timer = out.json && out.json.state && out.json.state.timer;
+ if (!timer) return { error: 'the timer was not set' };
+ return { timer: wire(timer), said: sayOf(timer) };
+}
+
+/** Stop the person's timer. */
+async function stop(subject, deps = {}) {
+ const out = await call(subject, { clear: true }, deps);
+ if (out.error) return out;
+ return { stopped: true, said: 'Timer stopped.' };
+}
+
+// "a 25 minute timer", "set a timer for 10 minutes", "focus timer", "pomodoro", "stop the timer".
+const STOP = /\b(?:stop|cancel|end|clear|remove|delete)\b[^.?!]{0,24}\btimers?\b/i;
+const ACTION = /\b(?:start|set|begin|put|give|launch|run)\b/i;
+const MINUTES = /\b(\d{1,3})\s*(?:minutes?|mins?|m)\b/i;
+const FOCUS = /\b(?:pomodoro|focus|deep\s+work)\b/i;
+
+/**
+ * The person's words when they plainly ask for a timer (Vibe then sets it even when the model only promised to).
+ * → { minutes, kind } to start one, { stop: true } to stop it, or null when they did not ask.
+ */
+function fromRequest(message) {
+ const flat = String(message || '').replace(/\s+/g, ' ').trim();
+ if (!flat || flat.length > 400) return null;
+ if (STOP.test(flat)) return { stop: true };
+ const focus = FOCUS.test(flat);
+ if (!/\btimers?\b/.test(flat) && !focus) return null;
+ const action = ACTION.test(flat);
+ const said = MINUTES.exec(flat);
+ if (!action && !focus && !said) return null; // a question about timers ("how does the timer work?") is not a request
+ if (said) {
+ const m = Number(said[1]);
+ if (!Number.isFinite(m) || m < 1 || m > MAX_MINUTES) return null; // out of range: let Vibe ask
+ // "how long is a 25 minute timer?" is a question, not a request for one.
+ if (!action && /\?\s*$/.test(flat)) return null;
+ return { minutes: m, kind: focus ? 'focus' : 'timer' };
+ }
+ // "a pomodoro" or "a focus timer" is 25 minutes of focus, the way the Dock's own focus block is.
+ if (focus) return { minutes: 25, kind: 'focus' };
+ // "start a timer" with no length: 25 minutes, the Dock's own focus length.
+ return { minutes: 25, kind: 'timer' };
+}
+
+function _setTokens(t) { tokens = t; }
+
+module.exports = { start, stop, fromRequest, sayOf, wire, _setTokens };
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * The deterministic net for the Dock's timer (plan L48 R2; server/rooms/ai-members.js afterPost): a plain "start a
+ * 25 minute timer" is set even when the model only promised it (small models often do), and the answer says what was
+ * set — but only when the timer really is set. When the Network refuses, the answer says nothing about a timer.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe timer net');
+let h, ann, ai, config, aiClient;
+const RUN = (content) => ({ id: 'run_1', status: 'succeeded', output: { choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] } });
+
+t('boot', async () => {
+ // The model answers in words and calls no tool: the case the net is there for.
+ ai = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' });
+ const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+ send('delta', { text: 'Sure, I will start that timer for you.' });
+ send('run', { run: RUN('Sure, I will start that timer for you.') });
+ res.end();
+ });
+ });
+ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ config = require('../server/config');
+ config.ai.internalUrl = `http://127.0.0.1:${ai.address().port}`;
+ aiClient = require('../server/ai/client');
+ aiClient._reset();
+ aiClient._setTokens({ getToken: async () => 'chat-ai-token' });
+ timer = require('../server/rooms/vibe-timer'); // after the boot: the config reads the environment
+ realStart = timer.start;
+ realStop = timer.stop;
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+});
+
+let timer = null;
+const asked = [];
+const TIMER = { kind: 'focus', label: null, started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:25:00.000Z', round: 1, rounds: 4, focus_min: 25, break_min: 5 };
+let realStart = null;
+let realStop = null;
+
+async function ask(text) {
+ const vibe = require('../server/rooms/vibe');
+ const rooms = require('../server/rooms/rooms');
+ const user = h.live.users.get(ann.id);
+ const room = await vibe.createRoom(user, text);
+ const message = await rooms.post(room, user, text, { ai: false });
+ const frames = [];
+ const reply = await require('../server/rooms/ai-members').afterPost(room, message, user, { broadcast: (f) => frames.push(f) });
+ return { frames, reply };
+}
+
+t('a plain "start a 25 minute timer" is set even when the model only promised it, and the answer says so', async () => {
+ asked.length = 0;
+ timer.start = async (subject, want) => { asked.push({ subject, want }); return { timer: TIMER, said: timer.sayOf(TIMER) }; };
+ try {
+ const { reply } = await ask('start a 25 minute timer');
+ assert.strictEqual(asked.length, 1, 'the timer was set');
+ assert.strictEqual(asked[0].subject, h.live.users.get(ann.id).subject_id, 'it is the person\'s own timer');
+ assert.deepStrictEqual(asked[0].want, { minutes: 25, kind: 'timer' });
+ assert.match(reply.message, /Timer set: 25 min/);
+ assert.match(reply.message, /It's in the Dock on every site\./);
+ } finally { timer.start = realStart; }
+});
+
+t('when the Network refuses, the answer does not say a timer was set', async () => {
+ asked.length = 0;
+ timer.start = async (subject, want) => { asked.push({ subject, want }); return { error: 'the timer is not available right now' }; };
+ try {
+ const { reply } = await ask('start a 25 minute timer');
+ assert.strictEqual(asked.length, 1, 'it was tried');
+ assert.ok(!/Timer set/.test(reply.message), `the answer says nothing about a timer: ${reply.message}`);
+ } finally { timer.start = realStart; }
+});
+
+t('"stop the timer" stops it, and says so', async () => {
+ asked.length = 0;
+ timer.stop = async (subject) => { asked.push({ subject }); return { stopped: true, said: 'Timer stopped.' }; };
+ try {
+ const { reply } = await ask('stop the timer');
+ assert.strictEqual(asked.length, 1);
+ assert.match(reply.message, /Timer stopped\./);
+ } finally { timer.stop = realStop; }
+});
+
+t.run(async () => { aiClient._reset(); if (h && h.close) await h.close(); await new Promise((r) => ai.close(r)); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * The Dock's timer from a Vibe chat (plan L48 R2; server/rooms/vibe-timer.js and the start_timer and stop_timer
+ * tools): only Vibe in its person's own chat has them; start_timer asks the Network to set that person's Dock timer
+ * (Chat's own network.dock.timer.write token, X-OV-Subject) and answers with the timer the Dock will count down;
+ * stop_timer clears it; a plain "a 25 minute timer" 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, timer;
+
+const t = suite('vibe timer');
+let h, ann;
+const sent = [];
+let netAnswer = null;
+const netFetch = 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 } = netAnswer(call);
+ return { ok: status < 300, status, json: async () => body };
+};
+// The timer the Dock reads (openvibe-shared/dock-timer.js start()).
+const TIMER = { kind: 'focus', label: 'Write the post', started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:25:00.000Z', round: 1, rounds: 4, focus_min: 25, break_min: 5 };
+const PLAIN = { kind: 'timer', started_at: '2026-11-01T09:00:00.000Z', ends_at: '2026-11-01T09:10:00.000Z' };
+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: netFetch, ...ctx }));
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ aiTools = require('../server/rooms/ai-tools');
+ timer = require('../server/rooms/vibe-timer');
+ timer._setTokens({ getToken: async () => 'chat-network-token' });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+ netAnswer = () => ({ status: 200, body: { state: { timer: TIMER }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });
+});
+
+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);
+ ['start_timer', 'stop_timer'].forEach((n) => assert.ok(names(vibeMember, ownChat).includes(n), n));
+ assert.ok(!names(vibeMember, { id: 2, assistant: 0 }).includes('start_timer'), 'Vibe added to a room: never');
+ assert.ok(!names({ handle: 'dealer' }, ownChat).includes('start_timer'), 'another AI member: never');
+ assert.match((await run('start_timer', { minutes: 25 }, { room: { id: 2, assistant: 0 } })).error, /no tool called start_timer/);
+});
+
+t('start_timer asks the Network to set that person\'s timer, and answers with the timer the Dock reads', async () => {
+ const out = await run('start_timer', { minutes: 25, label: 'Write the post', focus_rounds: 4 });
+ const call = sent[sent.length - 1];
+ assert.strictEqual(call.method, 'PUT');
+ assert.match(call.url, /\/api\/v1\/people\/usr_[0-9A-HJKMNP-TV-Z]{26}\/dock\/timer$/);
+ assert.strictEqual(call.subject, h.live.users.get(ann.id).subject_id, 'X-OV-Subject is the person');
+ assert.strictEqual(call.auth, 'Bearer chat-network-token');
+ assert.deepStrictEqual(call.body, { minutes: 25, kind: 'focus', label: 'Write the post', rounds: 4 });
+ assert.deepStrictEqual(out.timer, TIMER);
+ assert.strictEqual(out.said, "Timer set: 25 min, focus. It's in the Dock on every site.");
+});
+
+t('a plain timer has no rounds and no focus', async () => {
+ netAnswer = () => ({ status: 200, body: { state: { timer: PLAIN }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });
+ const out = await run('start_timer', { minutes: 10 });
+ assert.deepStrictEqual(sent[sent.length - 1].body, { minutes: 10 }, 'no kind, no rounds, no label');
+ assert.strictEqual(out.timer.kind, 'timer');
+ assert.strictEqual(out.said, "Timer set: 10 min. It's in the Dock on every site.");
+});
+
+t('stop_timer clears it', async () => {
+ netAnswer = () => ({ status: 200, body: { state: { timer: null }, counts: { running: 0, waiting: 0, unread: 0 }, sources_partial: false } });
+ const out = await run('stop_timer', {});
+ assert.deepStrictEqual(sent[sent.length - 1].body, { clear: true });
+ assert.strictEqual(out.stopped, true);
+ assert.strictEqual(out.said, 'Timer stopped.');
+});
+
+t('a timer outside 1 to 180 minutes never reaches the Network', async () => {
+ const before = sent.length;
+ assert.match((await run('start_timer', { minutes: 0 })).error, /1 to 180/);
+ assert.match((await run('start_timer', { minutes: 200 })).error, /1 to 180/);
+ assert.strictEqual(sent.length, before, 'nothing was sent');
+});
+
+t('the Network\'s refusal reaches the model', async () => {
+ netAnswer = () => ({ status: 422, body: { code: 'dock.invalid', detail: 'minutes: must be integer' } });
+ const out = await run('start_timer', { minutes: 25 });
+ assert.match(out.error, /minutes: must be integer/);
+ netAnswer = () => ({ status: 403, body: { code: 'capability.denied', detail: 'network.dock.timer.write not granted' } });
+ assert.match((await run('start_timer', { minutes: 25 })).error, /not granted/);
+});
+
+t('a plain "a 25 minute timer" is recognised; a question about timers is not', async () => {
+ assert.deepStrictEqual(timer.fromRequest('a 25 minute timer'), { minutes: 25, kind: 'timer' });
+ assert.deepStrictEqual(timer.fromRequest('Vibe, start a 25 minute focus timer'), { minutes: 25, kind: 'focus' });
+ assert.deepStrictEqual(timer.fromRequest('set a timer for 10 minutes'), { minutes: 10, kind: 'timer' });
+ assert.deepStrictEqual(timer.fromRequest('focus timer'), { minutes: 25, kind: 'focus' });
+ assert.deepStrictEqual(timer.fromRequest('give me a pomodoro'), { minutes: 25, kind: 'focus' });
+ assert.deepStrictEqual(timer.fromRequest('stop the timer'), { stop: true });
+ assert.deepStrictEqual(timer.fromRequest('can you cancel my timer?'), { stop: true });
+ assert.strictEqual(timer.fromRequest('how does the timer work?'), null, 'a question is not a request');
+ assert.strictEqual(timer.fromRequest('what is in my dock?'), null);
+ assert.strictEqual(timer.fromRequest('start a 200 minute timer'), null, 'out of range: let Vibe ask');
+});
+
+t('done', async () => { await h.close(); });
+
+t.run(async () => { if (h && h.close) await h.close(); });