openvibers/openvibe.chat Commits

Vibe can start the person's Dock timer (#291)

OP OpenVibers committed 13 days ago · 4238a436f1 · 5 files, +363 −3

Plan L48 R2: "Vibe, start a 25-minute focus timer" starts the timer in the
Dock's pill on every OpenVibe site, the way Vibe already sets reminders.

Vibe gets two more tools in the person's own chat (server/rooms/ai-tools.js):
start_timer({ minutes, label?, focus_rounds? }) and stop_timer(). They ask
the Network to set or clear that person's Dock timer with Chat's own
network.dock.timer.write token, naming the person with X-OV-Subject, and
answer with the timer the Dock will count down.

A plain "a 25 minute timer", "set a timer for 10 minutes", "focus timer"
or "pomodoro" is recognised even when the model only promised it (small
models often do), and the answer says what was set - but only when the call
really succeeded.

Changes

ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex d668e0c..c479a7d 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -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),
ddiff --git a/server/rooms/ai-tools.js b/server/rooms/ai-tools.js
iindex d10789d..0b29ecf 100644
---- a/server/rooms/ai-tools.js
++++ b/server/rooms/ai-tools.js
@@@ -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 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.',
{ 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 };
}
ddiff --git a/server/rooms/vibe-timer.js b/server/rooms/vibe-timer.js
nnew file mode 100644
iindex 0000000..57554d2
---- /dev/null
++++ b/server/rooms/vibe-timer.js
@@@ -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 };
ddiff --git a/test/vibe-timer-net.test.js b/test/vibe-timer-net.test.js
nnew file mode 100644
iindex 0000000..edd6a7d
---- /dev/null
++++ b/test/vibe-timer-net.test.js
@@@ -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)); });
ddiff --git a/test/vibe-timer.test.js b/test/vibe-timer.test.js
nnew file mode 100644
iindex 0000000..cc93451
---- /dev/null
++++ b/test/vibe-timer.test.js
@@@ -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(); });