openvibers/openvibe.chat Commits
Chat: what Vibe really did, kept with its answer, and an undo for it (#290)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- What Vibe really did in its answer (plan L48, V2: the result chips the Dock and Chat show for a Vibe answer):++-- the reminders and timers it set or stopped for the person, kept with the answer in room_messages.results as a++-- JSON list of at most four { kind, action, id?, title, when?, url? } (server/rooms/vibe-results.js). A nullable++-- column on the table the messages live in: the results live and die with the answer, they are read with the++-- message and never on their own, and no new table needs a person column of its own (ADR-033). Null for every++-- message that is not a Vibe answer with something to show.++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS results jsonb;@@@ -34,6 +34,7 @@ const config = require('../config');const aiClient = require('../ai/client');const credentials = require('../ai/credentials');const aiTools = require('./ai-tools');++const vibeResults = require('./vibe-results');const MAX_PER_ROOM = 3;const TRANSCRIPT = 30;@@@ -305,7 +306,7 @@ function userPrompt({ member, context, transcript, askedBy, text }) {* The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn* with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).*/--async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, signal = null }) {++async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, results = null, signal = null }) {const tools = aiTools.definitions(member, room, { memory: memory !== null });const vibe = Number(room.assistant) === 1;const tz = timezoneOf(timezone);@@@ -334,6 +335,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex// tool still judges each result against the constraints they stated (11-01).const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, requestText: text, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });if (steps) require('./how').cite(steps, c.function.name, tool.result);++ // What Vibe really did (a reminder or a timer it set or stopped): kept with the answer, as the chips.++ const entry = vibeResults.ofTool(c.function.name, tool.result);++ if (entry && results) results.push(entry);messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: tool.text });}}@@@ -353,10 +357,13 @@ async function afterPost(room, message, user, deps = {}) {if (!member) return null;const rooms = deps.rooms || require('./rooms');const broadcast = deps.broadcast || ((frame) => require('../chat/chat-server').broadcastToRoom(room.id, frame));++ const results = []; // what Vibe really did for the person in this answer (the chips; server/rooms/vibe-results.js)const say = async (text, how = null) => {const posted = await rooms.postAsAi(room, member, text, how ? { how } : {});-- broadcast({ type: 'room_message', room: room.slug, message: posted });-- return posted;++ // The results belong to the answer: they are written with it and read with it (the Dock, the /vibe page).++ const saved = await vibeResults.save(posted && posted.id, results).catch(() => null);++ broadcast({ type: 'room_message', room: room.slug, message: saved || posted });++ return saved || posted;};if (inFlight.has(room.id)) return null;const own = member.engine === 'own' && member.engine_subject;@@@ -402,7 +409,7 @@ async function afterPost(room, message, user, deps = {}) {if (controller) stoppers.set(room.id, controller);if (config.ai.memberTools) {const images = await picturesFor(message, history, user).catch(() => []);-- try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }++ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, results, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }}if (controller && controller.signal.aborted) {const sofar = clean(pending || '', 7900);@@@ -419,7 +426,32 @@ async function afterPost(room, message, user, deps = {}) {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 (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).`;++ const entry = vibeResults.ofTool('set_reminder', { reminder: set.reminder });++ if (entry) results.push(entry);++ }++ }++ }++ // 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}`;++ // What Vibe really did (the timer it started): kept with the answer, as the chips.++ const entry = vibeResults.ofTool('start_timer', set);++ if (entry) results.push(entry);++ } else if (set && set.stopped) {++ text = `${text}\n\n⏱ ${set.said}`;++ const entry = vibeResults.ofTool('stop_timer', set);++ if (entry) results.push(entry);++ }}}// A plain "start a 25 minute timer" is set even when the model only promised it (small models often do);@@@ -312,7 +312,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {}// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.how,++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.how, m.results,CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle ELSE u.username END AS username,CASE WHEN m.ai_member_id IS NOT NULL THEN a.name ELSE u.display_name END AS display_name,u.profile_color, u.role AS user_role@@@ -7,7 +7,11 @@** GET /api/vibe/chat?slug=&after= the chat (by slug, else the latest) and its messages: { chat, messages }, or* { chat: null } before the first. after = a message id: only what came after it-- * (the Dock polls this for Vibe's answer).++ * (the Dock polls this for Vibe's answer). A Vibe message carries what Vibe really++ * did (results: the reminders and timers it set or stopped), absent when none.++ * POST /api/vibe/undo { message_id, index }++ * undo one of those results: the reminder is stopped, the timer too, and the++ * result is marked undone. The person themself, in their own chat, within a day.* POST /api/vibe/ask { message, slug?, timezone?, fresh? }* post into that chat, else the latest one if it moved in the last 6 hours, else* a new chat (fresh: always new). Vibe answers in the background, as on Chat.@@@ -23,6 +27,7 @@ const { requireAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const rooms = require('./rooms');const vibe = require('./vibe');++const vibeResults = require('./vibe-results');const aiClient = require('../ai/client');const router = express.Router();@@@ -31,6 +36,7 @@ router.use(requireAuth);const SIX_HOURS = 6 * 3600 * 1000;const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });++const undone = limits('chat.vibe.undo', { minute: 12, hour: 300 }); // like ask: undoing is as rare as askingconst listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });const AUDIO_MAX = 1024 * 1024;const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];@@@ -38,7 +44,11 @@ const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];const personOnly = (req, res, next) => (req.authSource === 'api_token' || req.authSource === 'dock' ? res.status(403).json({ error: 'Vibe is for the person themself', code: 'vibe.person_only' }) : next());const zone = (tz) => { try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return String(tz).slice(0, 64); } catch { return null; } };const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };--const wire = (m) => ({ id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at) });++// A Vibe message: its text, and what Vibe really did for the person (the chips; server/rooms/vibe-results.js).++const wire = (m) => {++ const results = vibeResults.list(m.results);++ return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at), ...(results.length ? { results } : {}) };++};const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);async function latest(user) {@@@ -55,6 +65,29 @@ router.get('/chat', personOnly, async (req, res) => {return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });});++/**++ * Undo what Vibe did in one of its answers: the reminder is stopped on OpenVibe.Today, the timer on the Dock, and the++ * result is marked undone so it is not offered again. The person themself (personOnly, like ask), only in their own++ * chat, only a result Vibe set, and only within a day of the answer.++ * POST /api/vibe/undo { message_id, index } → { results, undone }++ */++router.post('/undo', personOnly, undone, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const messageId = Number(b.message_id);++ const index = Number(b.index);++ if (!Number.isInteger(messageId) || messageId <= 0) return res.status(422).json({ error: 'Which answer?', code: 'vibe.undo_message' });++ if (!Number.isInteger(index) || index < 0) return res.status(422).json({ error: 'Which result?', code: 'vibe.undo_index' });++ try {++ const out = await vibeResults.undo({ messageId, index, user: req.user });++ return res.set('Cache-Control', 'private, no-store').json(out);++ } catch (err) {++ const status = err && err.status ? err.status : 500;++ return res.status(status).set('Cache-Control', 'private, no-store').json({++ error: status === 500 ? 'That could not be undone' : String(err.message || 'That could not be undone'),++ code: (err && err.code) || 'vibe.undo_failed',++ });++ }++});const readAudio = express.raw({ type: AUDIO_TYPES, limit: AUDIO_MAX + 1 });const audioBody = (req, res, next) => readAudio(req, res, (err) => {nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * What Vibe really did in its answer (plan L48, V2; the owner, 2026-11-01: "use a voice feature to tell it to set a++ * reminder… have a conversation with your Vibe AI"). Vibe's answer used to be plain text, so the Dock's Vibe tab++ * (OpenVibe.Shared/dock-vibe.js) and Chat's own /vibe page could only show what Vibe said, never what Vibe did.++ *++ * When a turn really sets a reminder (set_reminder) or a timer (start_timer), or stops one (stop_reminder,++ * stop_timer), what it did is kept with the answer: a small JSON list in room_messages.results (nullable jsonb,++ * migrations/0036_vibe_results.sql), at most four items, each++ *++ * { kind: 'reminder' | 'timer', action: 'set' | 'stopped', id?, title, when?, url? }++ *++ * - a reminder: its id (rem_…), its title ("Stretch"), when it fires in words ("every 2 hours") and the page where++ * the person manages it (https://openvibe.today/me#reminders — the only address a result may carry).++ * - a timer: what it is ("25 min focus"). The Dock holds one timer per person, so a timer has no id.++ *++ * The list belongs to the answer: it is written when the answer is posted and it goes with the message. A result the++ * person undoes (POST /api/vibe/undo) is kept, with its action changed to 'stopped': the chip then reads as stopped++ * and is not offered again. The age of a result is the age of its answer (the list is written with it), so no result++ * needs a timestamp of its own.++ *++ * ofTool(name, result) → the entry a tool call produced, or null++ * save(messageId, entries) → keep them with the answer; the answer's row++ * list(value) → the entries as they are kept (a JSON list, at most four)++ * undo({ messageId, index, user }) → stop that reminder or that timer, and mark the result undone++ */++const db = require('../db/database');++++const MAX = 4;++const KINDS = ['reminder', 'timer'];++const ACTIONS = ['set', 'stopped'];++const REMINDER_URL = 'https://openvibe.today/me#reminders';++const DAY = 24 * 3600 * 1000;++++class ResultError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }++const fail = (status, code, message) => { throw new ResultError(status, code, message); };++++const text = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);++++/** One entry as it is kept and shown: only these fields, and a URL only for a reminder on openvibe.today. */++function entry({ kind, action, id, title, when, url } = {}) {++ const k = KINDS.includes(String(kind)) ? String(kind) : null;++ const a = ACTIONS.includes(String(action)) ? String(action) : null;++ const t = text(title, 120);++ if (!k || !a || !t) return null; // a chip without a title says nothing++ const out = { kind: k, action: a };++ const i = text(id, 64);++ if (i) out.id = i;++ out.title = t;++ const w = text(when, 120);++ if (w) out.when = w;++ const u = text(url, 300);++ if (u && k === 'reminder' && /^https:\/\/openvibe\.today\//.test(u)) out.url = u;++ return out;++}++++/** A reminder, set or stopped: Today's own words for it ("Every 2 hours, 9 am–5 pm"). */++function reminder(r, action) {++ if (!r || typeof r !== 'object') return null;++ return entry({ kind: 'reminder', action, id: r.id, title: r.title, when: r.when || r.summary || r.next_at, url: REMINDER_URL });++}++++/** How long the timer runs: said outright ("minutes"), or the length of a focus block, or between its ends. */++function minutesOf(t) {++ const said = Number(t.minutes);++ if (Number.isFinite(said) && said > 0) return Math.round(said);++ const focus = Number(t.focus_min);++ if (Number.isFinite(focus) && focus > 0) return Math.round(focus);++ const from = Date.parse(t.started_at || '');++ const to = Date.parse(t.ends_at || '');++ if (Number.isFinite(from) && Number.isFinite(to) && to > from) return Math.round((to - from) / 60000);++ return 0;++}++++/** A timer, set or stopped: "25 min focus" (the Dock's own shape: minutes or a focus block, kind, label, rounds). */++function timer(t, action) {++ // A stopped timer comes back as "yes, it is stopped" and nothing more: the Dock's own word for it.++ if (t === true) return entry({ kind: 'timer', action, title: 'Timer' });++ if (!t || typeof t !== 'object') return null;++ const minutes = minutesOf(t);++ const label = text(t.label, 60);++ const kind = text(t.kind, 40);++ const bits = [];++ if (minutes) bits.push(minutes + ' min');++ if (label) bits.push(label);++ else if (kind === 'focus') bits.push(kind); // "25 min focus"; a plain countdown needs no word after it++ const title = bits.join(' ') || text(t.title, 120) || (kind === 'focus' ? 'Focus' : 'Timer');++ return entry({ kind: 'timer', action, title, when: text(t.when || t.summary, 120) });++}++++/** The entry a tool call produced, or null when the call changed nothing the person can see or undo. */++function ofTool(name, result) {++ if (!result || typeof result !== 'object') return null;++ if (name === 'set_reminder') return reminder(result.reminder, 'set');++ if (name === 'stop_reminder') return reminder(result.stopped, 'stopped');++ if (name === 'start_timer') return timer(result.timer || result.started, 'set');++ if (name === 'stop_timer') return timer(result.stopped || result.timer, 'stopped');++ return null;++}++++/** The entries as they are kept, whatever the driver gave back (a jsonb column comes back parsed). */++function list(value) {++ let raw = value;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ if (!Array.isArray(raw)) return [];++ return raw.map((e) => entry(e || {})).filter(Boolean).slice(0, MAX);++}++++/** Keep the results with the answer: at most four, in the order they happened. → the answer's row, or null. */++async function save(messageId, entries) {++ const kept = (Array.isArray(entries) ? entries : []).map((e) => entry(e || {})).filter(Boolean).slice(0, MAX);++ const id = Number(messageId) || 0;++ if (!id || !kept.length) return null;++ await db.run('UPDATE room_messages SET results = ? WHERE id = ?', [JSON.stringify(kept), id]);++ const row = await require('./rooms').messageById(id);++ if (row) row.results = kept; // the same list, parsed: what the Dock and the page read++ return row;++}++++/** The Dock's timer, stopped with the tool Vibe itself uses, so the Dock and Vibe keep one timer between them. */++async function stopTimer(row, user) {++ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [row.room_id]);++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [row.room_id]);++ const out = await require('./ai-tools').runWithResult('stop_timer', '{}', { member, room, user, subject: user.subject_id || null });++ const result = out && out.result;++ if (!result || result.error) fail(502, 'vibe.undo_failed', result && result.error ? String(result.error).slice(0, 200) : 'The timer could not be stopped');++ return result;++}++++/**++ * Undo one result of an answer: the reminder is stopped on OpenVibe.Today, the timer on the Dock. Only the person++ * whose chat it is may undo it, only what Vibe set, and only within a day of the answer; the result is then marked++ * undone (its action becomes 'stopped') so it is not offered again.++ */++async function undo({ messageId, index, user }) {++ const row = await db.get('SELECT m.id AS message_id, m.created_at, m.ai_member_id, m.results, r.id AS room_id, r.owner_id, r.assistant '++ + 'FROM room_messages m JOIN rooms r ON r.id = m.room_id WHERE m.id = ?', [Number(messageId) || 0]);++ // Someone else's answer, a message that is not an answer, or a chat that is not a Vibe chat: all the same 404.++ if (!row || !row.ai_member_id || Number(row.assistant) !== 1 || Number(row.owner_id) !== Number(user.id)) {++ fail(404, 'vibe.undo_not_found', 'No such answer');++ }++ const entries = list(row.results);++ const one = entries[index];++ if (!one) fail(404, 'vibe.undo_not_found', 'No such result');++ if (one.action !== 'set') fail(409, 'vibe.undo_already', 'That is already undone');++ const stamp = String(row.created_at || '');++ const at = Date.parse(stamp.includes('T') ? stamp : stamp.replace(' ', 'T') + 'Z');++ if (!Number.isFinite(at) || Date.now() - at > DAY) fail(410, 'vibe.undo_gone', 'That is too old to undo');++ if (one.kind === 'reminder') {++ if (!one.id) fail(409, 'vibe.undo_not_undoable', 'That reminder cannot be stopped');++ const out = await require('./vibe-reminders').stop(user.subject_id || null, one.id);++ if (!out || out.error) fail(502, 'vibe.undo_failed', (out && out.error) ? String(out.error).slice(0, 200) : 'The reminder could not be stopped');++ } else {++ await stopTimer(row, user);++ }++ // Mark it undone, and only if it was still undoable a moment ago (two taps at once: the second one loses).++ const next = entries.slice();++ next[index] = { ...one, action: 'stopped' };++ const written = await db.run('UPDATE room_messages SET results = ? WHERE id = ? AND results = ?',++ [JSON.stringify(next), row.message_id, JSON.stringify(entries)]);++ if (!written.changes) fail(409, 'vibe.undo_already', 'That is already undone');++ return { results: next, undone: { kind: one.kind, id: one.id || null, title: one.title } };++}++++module.exports = { MAX, KINDS, ACTIONS, REMINDER_URL, ResultError, entry, ofTool, list, save, undo };nnew file mode 100644@@@ -0,0 +1,191 @@++'use strict';++/**++ * What Vibe really did in its answer (plan L48, V2: the result chips; server/rooms/vibe-results.js,++ * migrations/0036_vibe_results.sql): the reminders and timers Vibe set or stopped are kept with the answer, as a++ * small JSON list on the message (room_messages.results), and the person can undo one of them++ * (POST /api/vibe/undo): only in their own chat, only what Vibe set, and only within a day of the answer.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe results');++let h, ann, bob, bot, aiClient;++let script = [];++const toolCall = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } });++const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });++const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: tc }, finish_reason: 'tool_calls' }] });++++// OpenVibe.Today, the stand-in: it takes the reminder Vibe sets and stops it again.++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 today = { calls: [] };++// OpenVibe.Network, the stand-in: it holds the person's Dock timer.++const net = { calls: [], timer: { kind: 'focus', 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 todayServer = http.createServer(async (req, res) => {++ const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });++ today.calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'], body: raw ? JSON.parse(raw) : null });++ const send = (status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); };++ if (req.method === 'POST' && req.url === '/api/v1/reminders') return send(201, { ...REM, source: 'vibe' });++ if (req.method === 'POST' && /\/api\/v1\/reminders\/rem_[0-9A-Z]+\/done$/.test(req.url)) return send(200, { ...REM, status: 'done' });++ if (req.method === 'GET' && req.url === '/api/v1/reminders') return send(200, { reminders: [REM] });++ return send(404, { error: 'not found' });++});++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++/** Vibe's answer in that chat, as the Dock reads it. */++async function answer(slug, after) {++ for (let i = 0; i < 80; i++) {++ const r = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(slug) + '&after=' + after, { token: ann.token });++ const v = (r.body.messages || []).find((m) => m.from === 'vibe');++ if (v) return v;++ await wait(50);++ }++ throw new Error('Vibe did not answer');++}++const ago = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().replace('T', ' ').slice(0, 19);++++t('boot', async () => {++ await new Promise((r) => todayServer.listen(0, '127.0.0.1', r));++ todayServer.unref();++ const local = 'http://127.0.0.1:' + todayServer.address().port;++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat', CHAT_TODAY_INTERNAL_URL: local } });++ aiClient = require('../server/ai/client');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ bot = h.addUser('bot', { subject: ids.newId('user'), apiScopes: ['rooms.read'] });++ for (const u of [ann, bob, bot]) await h.ctx.upsertUser(h.live.users.get(u.id));++ aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? (script.shift() || (() => say('Sure.')))() : { text: 'plain' } } }) } });++});++++t('the reminder Vibe set is kept with its answer', async () => {++ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set: **Stretch**, every 2 hours.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });++ assert.strictEqual(asked.status, 201, asked.text);++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ assert.deepStrictEqual(a.results, [{++ kind: 'reminder', action: 'set', id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders',++ }], 'the chip says what Vibe did');++ const chat = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(asked.body.chat.slug), { token: ann.token });++ const mine = chat.body.messages.find((m) => m.id === asked.body.message.id);++ assert.strictEqual(mine.results, undefined, 'the person\'s own message has no results');++ const stored = await h.db.getDb().prepare('SELECT results FROM room_messages WHERE id = ?').get(a.id);++ assert.deepStrictEqual(stored.results, a.results, 'the same list is kept with the message');++});++++t('an answer with nothing to show carries no results', async () => {++ script = [() => say('Hello!')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'hello', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ assert.strictEqual(a.results, undefined);++});++++t('a plain "remind me …" the model only promised is a result too', async () => {++ script = [() => say('Of course, I will remind you.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'remind me to stretch every 2 hours from 9 to 5', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ assert.deepStrictEqual((a.results || []).map((r) => [r.kind, r.action, r.title]), [['reminder', 'set', 'Stretch']], 'the net set it, so it is kept');++});++++t('undo stops the reminder and is offered only once', async () => {++ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ today.calls.length = 0;++ const undone = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(undone.status, 200, undone.text);++ assert.deepStrictEqual(undone.body.results, [{ kind: 'reminder', action: 'stopped', id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders' }]);++ assert.deepStrictEqual(undone.body.undone, { kind: 'reminder', id: REM.id, title: 'Stretch' });++ const stopped = today.calls.filter((c) => c.url.endsWith('/done'));++ assert.strictEqual(stopped.length, 1, 'the reminder was stopped at OpenVibe.Today');++ assert.strictEqual(stopped[0].subject, ann.subject_id, 'as the person themself');++ const again = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(again.status, 409, again.text);++ assert.strictEqual(today.calls.filter((c) => c.url.endsWith('/done')).length, 1, 'and not stopped twice');++ const chat = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(asked.body.chat.slug), { token: ann.token });++ const kept = chat.body.messages.find((m) => m.id === a.id);++ assert.deepStrictEqual(kept.results[0].action, 'stopped', 'the chip reads as stopped, not as something to undo');++});++++t('only the person themself may undo it', async () => {++ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ const other = await h.http('POST', '/api/vibe/undo', { token: bob.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(other.status, 404, 'someone else\'s answer is not there to undo');++ const api = await h.http('POST', '/api/vibe/undo', { token: bot.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(api.status, 403, 'an API token is not a person');++ const nowhere = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: 999999, index: 0 } });++ assert.strictEqual(nowhere.status, 404);++});++++t('a result of a day and a half ago is gone for good', async () => {++ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ await h.db.getDb().prepare('UPDATE room_messages SET created_at = ? WHERE id = ?').run(ago(25), a.id);++ const old = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(old.status, 410, old.text);++ today.calls.length = 0;++ assert.strictEqual(today.calls.length, 0, 'and nothing was stopped');++});++++t('at most four results are kept with an answer', async () => {++ script = [() => callTools(++ toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' }),++ toolCall('c2', 'set_reminder', { text: 'walk every 3 hours from 9 to 5' }),++ toolCall('c3', 'set_reminder', { text: 'water every 4 hours from 9 to 5' }),++ toolCall('c4', 'set_reminder', { text: 'read every 5 hours from 9 to 5' }),++ toolCall('c5', 'set_reminder', { text: 'rest every 6 hours from 9 to 5' })), () => say('All set.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'set me five reminders', fresh: true } });++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ assert.strictEqual((a.results || []).length, 4, 'four is enough for a chip row');++});++++t('the Dock\'s own timer shape reads as a chip', async () => {++ const results = require('../server/rooms/vibe-results');++ assert.deepStrictEqual(results.ofTool('start_timer', { timer: { kind: 'focus', focus_min: 25, rounds: 4, started_at: '2026-11-01T10:00:00.000Z', ends_at: '2026-11-01T10:25:00.000Z' } }),++ { kind: 'timer', action: 'set', title: '25 min focus' }, 'a focus block says how long it runs');++ assert.deepStrictEqual(results.ofTool('start_timer', { timer: { kind: 'timer', minutes: 10, label: 'Coffee' } }),++ { kind: 'timer', action: 'set', title: '10 min Coffee' }, 'a plain countdown says so too');++ assert.deepStrictEqual(results.ofTool('stop_timer', { stopped: { kind: 'focus', focus_min: 25 } }),++ { kind: 'timer', action: 'stopped', title: '25 min focus' });++ assert.strictEqual(results.ofTool('start_timer', { error: 'no timer' }), null, 'a tool that changed nothing is no chip');++ assert.strictEqual(results.ofTool('web_search', { ok: true }), null, 'and neither is something else Vibe did');++});++++t('the timer Vibe started is kept with its answer, and undo stops it on the Dock', async () => {++ const timer = require('../server/rooms/vibe-timer');++ const realFetch = globalThis.fetch;++ timer._setTokens({ getToken: async () => 'chat-network-token' });++ globalThis.fetch = async (url, o = {}) => {++ const s = String(url);++ if (!s.includes('/dock/timer')) return realFetch(url, o);++ net.calls.push({ method: o.method, body: o.body ? JSON.parse(o.body) : null, subject: o.headers && o.headers['X-OV-Subject'] });++ return { ok: true, status: 200, json: async () => ({ state: { timer: net.timer }, counts: {}, sources_partial: false }) };++ };++ try {++ script = [() => say('Sure, I will start that timer for you.')];++ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'start a 25 minute focus timer', fresh: true } });++ assert.strictEqual(asked.status, 201, asked.text);++ const a = await answer(asked.body.chat.slug, asked.body.message.id);++ assert.deepStrictEqual(a.results, [{ kind: 'timer', action: 'set', title: '25 min focus' }], 'the chip says what Vibe did');++ assert.deepStrictEqual(net.calls[net.calls.length - 1].body, { minutes: 25, kind: 'focus', rounds: 4 }, 'the timer was asked for');++ const undone = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(undone.status, 200, undone.text);++ assert.deepStrictEqual(undone.body.results, [{ kind: 'timer', action: 'stopped', title: '25 min focus' }], 'the chip reads as stopped');++ const cleared = net.calls.filter((c) => c.body && c.body.clear === true);++ assert.strictEqual(cleared.length, 1, 'the timer was stopped at OpenVibe.Network');++ assert.strictEqual(cleared[0].subject, ann.subject_id, 'as the person themself');++ const again = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });++ assert.strictEqual(again.status, 409, again.text);++ assert.strictEqual(net.calls.filter((c) => c.body && c.body.clear === true).length, 1, 'and not stopped twice');++ } finally {++ globalThis.fetch = realFetch;++ timer._setTokens(null);++ }++});++++t('done', async () => { if (aiClient) aiClient._setClient(null); todayServer.close(); await h.close(); });++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- What Vibe really did in its answer (plan L48, V2: the result chips the Dock and Chat show for a Vibe answer):
+-- the reminders and timers it set or stopped for the person, kept with the answer in room_messages.results as a
+-- JSON list of at most four { kind, action, id?, title, when?, url? } (server/rooms/vibe-results.js). A nullable
+-- column on the table the messages live in: the results live and die with the answer, they are read with the
+-- message and never on their own, and no new table needs a person column of its own (ADR-033). Null for every
+-- message that is not a Vibe answer with something to show.
+ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS results jsonb;
@@ -34,6 +34,7 @@ const config = require('../config');
@@ -34,6 +34,7 @@ const config = require('../config');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
const credentials = require('../ai/credentials');
const credentials = require('../ai/credentials');
const aiTools = require('./ai-tools');
const aiTools = require('./ai-tools');
+const vibeResults = require('./vibe-results');
const MAX_PER_ROOM = 3;
const MAX_PER_ROOM = 3;
const TRANSCRIPT = 30;
const TRANSCRIPT = 30;
@@ -305,7 +306,7 @@ function userPrompt({ member, context, transcript, askedBy, text }) {
@@ -305,7 +306,7 @@ function userPrompt({ member, context, transcript, askedBy, text }) {
* The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn
* The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn
* with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).
* with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).
*/
*/
-async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, signal = null }) {
+async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, results = null, signal = null }) {
const tools = aiTools.definitions(member, room, { memory: memory !== null });
const tools = aiTools.definitions(member, room, { memory: memory !== null });
const vibe = Number(room.assistant) === 1;
const vibe = Number(room.assistant) === 1;
const tz = timezoneOf(timezone);
const tz = timezoneOf(timezone);
@@ -334,6 +335,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
@@ -334,6 +335,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
// tool still judges each result against the constraints they stated (11-01).
// tool still judges each result against the constraints they stated (11-01).
const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, requestText: text, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, requestText: text, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
if (steps) require('./how').cite(steps, c.function.name, tool.result);
if (steps) require('./how').cite(steps, c.function.name, tool.result);
+ // What Vibe really did (a reminder or a timer it set or stopped): kept with the answer, as the chips.
+ const entry = vibeResults.ofTool(c.function.name, tool.result);
+ if (entry && results) results.push(entry);
messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: tool.text });
messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: tool.text });
}
}
}
}
@@ -353,10 +357,13 @@ async function afterPost(room, message, user, deps = {}) {
@@ -353,10 +357,13 @@ async function afterPost(room, message, user, deps = {}) {
if (!member) return null;
if (!member) return null;
const rooms = deps.rooms || require('./rooms');
const rooms = deps.rooms || require('./rooms');
const broadcast = deps.broadcast || ((frame) => require('../chat/chat-server').broadcastToRoom(room.id, frame));
const broadcast = deps.broadcast || ((frame) => require('../chat/chat-server').broadcastToRoom(room.id, frame));
+ const results = []; // what Vibe really did for the person in this answer (the chips; server/rooms/vibe-results.js)
const say = async (text, how = null) => {
const say = async (text, how = null) => {
const posted = await rooms.postAsAi(room, member, text, how ? { how } : {});
const posted = await rooms.postAsAi(room, member, text, how ? { how } : {});
- broadcast({ type: 'room_message', room: room.slug, message: posted });
- return posted;
+ // The results belong to the answer: they are written with it and read with it (the Dock, the /vibe page).
+ const saved = await vibeResults.save(posted && posted.id, results).catch(() => null);
+ broadcast({ type: 'room_message', room: room.slug, message: saved || posted });
+ return saved || posted;
};
};
if (inFlight.has(room.id)) return null;
if (inFlight.has(room.id)) return null;
const own = member.engine === 'own' && member.engine_subject;
const own = member.engine === 'own' && member.engine_subject;
@@ -402,7 +409,7 @@ async function afterPost(room, message, user, deps = {}) {
@@ -402,7 +409,7 @@ async function afterPost(room, message, user, deps = {}) {
if (controller) stoppers.set(room.id, controller);
if (controller) stoppers.set(room.id, controller);
if (config.ai.memberTools) {
if (config.ai.memberTools) {
const images = await picturesFor(message, history, user).catch(() => []);
const images = await picturesFor(message, history, user).catch(() => []);
- try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
+ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, results, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
}
}
if (controller && controller.signal.aborted) {
if (controller && controller.signal.aborted) {
const sofar = clean(pending || '', 7900);
const sofar = clean(pending || '', 7900);
@@ -419,7 +426,32 @@ async function afterPost(room, message, user, deps = {}) {
@@ -419,7 +426,32 @@ async function afterPost(room, message, user, deps = {}) {
const words = require('./vibe-reminders').fromRequest(message.message);
const words = require('./vibe-reminders').fromRequest(message.message);
if (words) {
if (words) {
const set = await require('./vibe-reminders').create(user.subject_id || null, { text: words, timezone: deps.timezone || null }).catch(() => ({}));
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 (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).`;
+ const entry = vibeResults.ofTool('set_reminder', { reminder: set.reminder });
+ if (entry) results.push(entry);
+ }
+ }
+ }
+ // 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}`;
+ // What Vibe really did (the timer it started): kept with the answer, as the chips.
+ const entry = vibeResults.ofTool('start_timer', set);
+ if (entry) results.push(entry);
+ } else if (set && set.stopped) {
+ text = `${text}\n\n⏱ ${set.said}`;
+ const entry = vibeResults.ofTool('stop_timer', set);
+ if (entry) results.push(entry);
+ }
}
}
}
}
// A plain "start a 25 minute timer" is set even when the model only promised it (small models often do);
// A plain "start a 25 minute timer" is set even when the model only promised it (small models often do);
@@ -312,7 +312,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
@@ -312,7 +312,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
}
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
-const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.how,
+const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.how, m.results,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name ELSE u.display_name END AS display_name,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name ELSE u.display_name END AS display_name,
u.profile_color, u.role AS user_role
u.profile_color, u.role AS user_role
@@ -7,7 +7,11 @@
@@ -7,7 +7,11 @@
*
*
* GET /api/vibe/chat?slug=&after= the chat (by slug, else the latest) and its messages: { chat, messages }, or
* GET /api/vibe/chat?slug=&after= the chat (by slug, else the latest) and its messages: { chat, messages }, or
* { chat: null } before the first. after = a message id: only what came after it
* { chat: null } before the first. after = a message id: only what came after it
- * (the Dock polls this for Vibe's answer).
+ * (the Dock polls this for Vibe's answer). A Vibe message carries what Vibe really
+ * did (results: the reminders and timers it set or stopped), absent when none.
+ * POST /api/vibe/undo { message_id, index }
+ * undo one of those results: the reminder is stopped, the timer too, and the
+ * result is marked undone. The person themself, in their own chat, within a day.
* POST /api/vibe/ask { message, slug?, timezone?, fresh? }
* POST /api/vibe/ask { message, slug?, timezone?, fresh? }
* post into that chat, else the latest one if it moved in the last 6 hours, else
* post into that chat, else the latest one if it moved in the last 6 hours, else
* a new chat (fresh: always new). Vibe answers in the background, as on Chat.
* a new chat (fresh: always new). Vibe answers in the background, as on Chat.
@@ -23,6 +27,7 @@ const { requireAuth } = require('../auth/auth');
@@ -23,6 +27,7 @@ const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
const vibe = require('./vibe');
const vibe = require('./vibe');
+const vibeResults = require('./vibe-results');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
const router = express.Router();
const router = express.Router();
@@ -31,6 +36,7 @@ router.use(requireAuth);
@@ -31,6 +36,7 @@ router.use(requireAuth);
const SIX_HOURS = 6 * 3600 * 1000;
const SIX_HOURS = 6 * 3600 * 1000;
const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });
const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });
+const undone = limits('chat.vibe.undo', { minute: 12, hour: 300 }); // like ask: undoing is as rare as asking
const listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });
const listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });
const AUDIO_MAX = 1024 * 1024;
const AUDIO_MAX = 1024 * 1024;
const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];
const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];
@@ -38,7 +44,11 @@ const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];
@@ -38,7 +44,11 @@ const AUDIO_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4'];
const personOnly = (req, res, next) => (req.authSource === 'api_token' || req.authSource === 'dock' ? res.status(403).json({ error: 'Vibe is for the person themself', code: 'vibe.person_only' }) : next());
const personOnly = (req, res, next) => (req.authSource === 'api_token' || req.authSource === 'dock' ? res.status(403).json({ error: 'Vibe is for the person themself', code: 'vibe.person_only' }) : next());
const zone = (tz) => { try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return String(tz).slice(0, 64); } catch { return null; } };
const zone = (tz) => { try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return String(tz).slice(0, 64); } catch { return null; } };
const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
-const wire = (m) => ({ id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at) });
+// A Vibe message: its text, and what Vibe really did for the person (the chips; server/rooms/vibe-results.js).
+const wire = (m) => {
+ const results = vibeResults.list(m.results);
+ return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at), ...(results.length ? { results } : {}) };
+};
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
async function latest(user) {
async function latest(user) {
@@ -55,6 +65,29 @@ router.get('/chat', personOnly, async (req, res) => {
@@ -55,6 +65,29 @@ router.get('/chat', personOnly, async (req, res) => {
return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });
return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });
});
});
+/**
+ * Undo what Vibe did in one of its answers: the reminder is stopped on OpenVibe.Today, the timer on the Dock, and the
+ * result is marked undone so it is not offered again. The person themself (personOnly, like ask), only in their own
+ * chat, only a result Vibe set, and only within a day of the answer.
+ * POST /api/vibe/undo { message_id, index } → { results, undone }
+ */
+router.post('/undo', personOnly, undone, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const messageId = Number(b.message_id);
+ const index = Number(b.index);
+ if (!Number.isInteger(messageId) || messageId <= 0) return res.status(422).json({ error: 'Which answer?', code: 'vibe.undo_message' });
+ if (!Number.isInteger(index) || index < 0) return res.status(422).json({ error: 'Which result?', code: 'vibe.undo_index' });
+ try {
+ const out = await vibeResults.undo({ messageId, index, user: req.user });
+ return res.set('Cache-Control', 'private, no-store').json(out);
+ } catch (err) {
+ const status = err && err.status ? err.status : 500;
+ return res.status(status).set('Cache-Control', 'private, no-store').json({
+ error: status === 500 ? 'That could not be undone' : String(err.message || 'That could not be undone'),
+ code: (err && err.code) || 'vibe.undo_failed',
+ });
+ }
+});
const readAudio = express.raw({ type: AUDIO_TYPES, limit: AUDIO_MAX + 1 });
const readAudio = express.raw({ type: AUDIO_TYPES, limit: AUDIO_MAX + 1 });
const audioBody = (req, res, next) => readAudio(req, res, (err) => {
const audioBody = (req, res, next) => readAudio(req, res, (err) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * What Vibe really did in its answer (plan L48, V2; the owner, 2026-11-01: "use a voice feature to tell it to set a
+ * reminder… have a conversation with your Vibe AI"). Vibe's answer used to be plain text, so the Dock's Vibe tab
+ * (OpenVibe.Shared/dock-vibe.js) and Chat's own /vibe page could only show what Vibe said, never what Vibe did.
+ *
+ * When a turn really sets a reminder (set_reminder) or a timer (start_timer), or stops one (stop_reminder,
+ * stop_timer), what it did is kept with the answer: a small JSON list in room_messages.results (nullable jsonb,
+ * migrations/0036_vibe_results.sql), at most four items, each
+ *
+ * { kind: 'reminder' | 'timer', action: 'set' | 'stopped', id?, title, when?, url? }
+ *
+ * - a reminder: its id (rem_…), its title ("Stretch"), when it fires in words ("every 2 hours") and the page where
+ * the person manages it (https://openvibe.today/me#reminders — the only address a result may carry).
+ * - a timer: what it is ("25 min focus"). The Dock holds one timer per person, so a timer has no id.
+ *
+ * The list belongs to the answer: it is written when the answer is posted and it goes with the message. A result the
+ * person undoes (POST /api/vibe/undo) is kept, with its action changed to 'stopped': the chip then reads as stopped
+ * and is not offered again. The age of a result is the age of its answer (the list is written with it), so no result
+ * needs a timestamp of its own.
+ *
+ * ofTool(name, result) → the entry a tool call produced, or null
+ * save(messageId, entries) → keep them with the answer; the answer's row
+ * list(value) → the entries as they are kept (a JSON list, at most four)
+ * undo({ messageId, index, user }) → stop that reminder or that timer, and mark the result undone
+ */
+const db = require('../db/database');
+
+const MAX = 4;
+const KINDS = ['reminder', 'timer'];
+const ACTIONS = ['set', 'stopped'];
+const REMINDER_URL = 'https://openvibe.today/me#reminders';
+const DAY = 24 * 3600 * 1000;
+
+class ResultError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
+const fail = (status, code, message) => { throw new ResultError(status, code, message); };
+
+const text = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+
+/** One entry as it is kept and shown: only these fields, and a URL only for a reminder on openvibe.today. */
+function entry({ kind, action, id, title, when, url } = {}) {
+ const k = KINDS.includes(String(kind)) ? String(kind) : null;
+ const a = ACTIONS.includes(String(action)) ? String(action) : null;
+ const t = text(title, 120);
+ if (!k || !a || !t) return null; // a chip without a title says nothing
+ const out = { kind: k, action: a };
+ const i = text(id, 64);
+ if (i) out.id = i;
+ out.title = t;
+ const w = text(when, 120);
+ if (w) out.when = w;
+ const u = text(url, 300);
+ if (u && k === 'reminder' && /^https:\/\/openvibe\.today\//.test(u)) out.url = u;
+ return out;
+}
+
+/** A reminder, set or stopped: Today's own words for it ("Every 2 hours, 9 am–5 pm"). */
+function reminder(r, action) {
+ if (!r || typeof r !== 'object') return null;
+ return entry({ kind: 'reminder', action, id: r.id, title: r.title, when: r.when || r.summary || r.next_at, url: REMINDER_URL });
+}
+
+/** How long the timer runs: said outright ("minutes"), or the length of a focus block, or between its ends. */
+function minutesOf(t) {
+ const said = Number(t.minutes);
+ if (Number.isFinite(said) && said > 0) return Math.round(said);
+ const focus = Number(t.focus_min);
+ if (Number.isFinite(focus) && focus > 0) return Math.round(focus);
+ const from = Date.parse(t.started_at || '');
+ const to = Date.parse(t.ends_at || '');
+ if (Number.isFinite(from) && Number.isFinite(to) && to > from) return Math.round((to - from) / 60000);
+ return 0;
+}
+
+/** A timer, set or stopped: "25 min focus" (the Dock's own shape: minutes or a focus block, kind, label, rounds). */
+function timer(t, action) {
+ // A stopped timer comes back as "yes, it is stopped" and nothing more: the Dock's own word for it.
+ if (t === true) return entry({ kind: 'timer', action, title: 'Timer' });
+ if (!t || typeof t !== 'object') return null;
+ const minutes = minutesOf(t);
+ const label = text(t.label, 60);
+ const kind = text(t.kind, 40);
+ const bits = [];
+ if (minutes) bits.push(minutes + ' min');
+ if (label) bits.push(label);
+ else if (kind === 'focus') bits.push(kind); // "25 min focus"; a plain countdown needs no word after it
+ const title = bits.join(' ') || text(t.title, 120) || (kind === 'focus' ? 'Focus' : 'Timer');
+ return entry({ kind: 'timer', action, title, when: text(t.when || t.summary, 120) });
+}
+
+/** The entry a tool call produced, or null when the call changed nothing the person can see or undo. */
+function ofTool(name, result) {
+ if (!result || typeof result !== 'object') return null;
+ if (name === 'set_reminder') return reminder(result.reminder, 'set');
+ if (name === 'stop_reminder') return reminder(result.stopped, 'stopped');
+ if (name === 'start_timer') return timer(result.timer || result.started, 'set');
+ if (name === 'stop_timer') return timer(result.stopped || result.timer, 'stopped');
+ return null;
+}
+
+/** The entries as they are kept, whatever the driver gave back (a jsonb column comes back parsed). */
+function list(value) {
+ let raw = value;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ if (!Array.isArray(raw)) return [];
+ return raw.map((e) => entry(e || {})).filter(Boolean).slice(0, MAX);
+}
+
+/** Keep the results with the answer: at most four, in the order they happened. → the answer's row, or null. */
+async function save(messageId, entries) {
+ const kept = (Array.isArray(entries) ? entries : []).map((e) => entry(e || {})).filter(Boolean).slice(0, MAX);
+ const id = Number(messageId) || 0;
+ if (!id || !kept.length) return null;
+ await db.run('UPDATE room_messages SET results = ? WHERE id = ?', [JSON.stringify(kept), id]);
+ const row = await require('./rooms').messageById(id);
+ if (row) row.results = kept; // the same list, parsed: what the Dock and the page read
+ return row;
+}
+
+/** The Dock's timer, stopped with the tool Vibe itself uses, so the Dock and Vibe keep one timer between them. */
+async function stopTimer(row, user) {
+ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [row.room_id]);
+ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [row.room_id]);
+ const out = await require('./ai-tools').runWithResult('stop_timer', '{}', { member, room, user, subject: user.subject_id || null });
+ const result = out && out.result;
+ if (!result || result.error) fail(502, 'vibe.undo_failed', result && result.error ? String(result.error).slice(0, 200) : 'The timer could not be stopped');
+ return result;
+}
+
+/**
+ * Undo one result of an answer: the reminder is stopped on OpenVibe.Today, the timer on the Dock. Only the person
+ * whose chat it is may undo it, only what Vibe set, and only within a day of the answer; the result is then marked
+ * undone (its action becomes 'stopped') so it is not offered again.
+ */
+async function undo({ messageId, index, user }) {
+ const row = await db.get('SELECT m.id AS message_id, m.created_at, m.ai_member_id, m.results, r.id AS room_id, r.owner_id, r.assistant '
+ + 'FROM room_messages m JOIN rooms r ON r.id = m.room_id WHERE m.id = ?', [Number(messageId) || 0]);
+ // Someone else's answer, a message that is not an answer, or a chat that is not a Vibe chat: all the same 404.
+ if (!row || !row.ai_member_id || Number(row.assistant) !== 1 || Number(row.owner_id) !== Number(user.id)) {
+ fail(404, 'vibe.undo_not_found', 'No such answer');
+ }
+ const entries = list(row.results);
+ const one = entries[index];
+ if (!one) fail(404, 'vibe.undo_not_found', 'No such result');
+ if (one.action !== 'set') fail(409, 'vibe.undo_already', 'That is already undone');
+ const stamp = String(row.created_at || '');
+ const at = Date.parse(stamp.includes('T') ? stamp : stamp.replace(' ', 'T') + 'Z');
+ if (!Number.isFinite(at) || Date.now() - at > DAY) fail(410, 'vibe.undo_gone', 'That is too old to undo');
+ if (one.kind === 'reminder') {
+ if (!one.id) fail(409, 'vibe.undo_not_undoable', 'That reminder cannot be stopped');
+ const out = await require('./vibe-reminders').stop(user.subject_id || null, one.id);
+ if (!out || out.error) fail(502, 'vibe.undo_failed', (out && out.error) ? String(out.error).slice(0, 200) : 'The reminder could not be stopped');
+ } else {
+ await stopTimer(row, user);
+ }
+ // Mark it undone, and only if it was still undoable a moment ago (two taps at once: the second one loses).
+ const next = entries.slice();
+ next[index] = { ...one, action: 'stopped' };
+ const written = await db.run('UPDATE room_messages SET results = ? WHERE id = ? AND results = ?',
+ [JSON.stringify(next), row.message_id, JSON.stringify(entries)]);
+ if (!written.changes) fail(409, 'vibe.undo_already', 'That is already undone');
+ return { results: next, undone: { kind: one.kind, id: one.id || null, title: one.title } };
+}
+
+module.exports = { MAX, KINDS, ACTIONS, REMINDER_URL, ResultError, entry, ofTool, list, save, undo };
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+'use strict';
+/**
+ * What Vibe really did in its answer (plan L48, V2: the result chips; server/rooms/vibe-results.js,
+ * migrations/0036_vibe_results.sql): the reminders and timers Vibe set or stopped are kept with the answer, as a
+ * small JSON list on the message (room_messages.results), and the person can undo one of them
+ * (POST /api/vibe/undo): only in their own chat, only what Vibe set, and only within a day of the answer.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe results');
+let h, ann, bob, bot, aiClient;
+let script = [];
+const toolCall = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } });
+const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });
+const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: tc }, finish_reason: 'tool_calls' }] });
+
+// OpenVibe.Today, the stand-in: it takes the reminder Vibe sets and stops it again.
+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 today = { calls: [] };
+// OpenVibe.Network, the stand-in: it holds the person's Dock timer.
+const net = { calls: [], timer: { kind: 'focus', 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 todayServer = http.createServer(async (req, res) => {
+ const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
+ today.calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'], body: raw ? JSON.parse(raw) : null });
+ const send = (status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); };
+ if (req.method === 'POST' && req.url === '/api/v1/reminders') return send(201, { ...REM, source: 'vibe' });
+ if (req.method === 'POST' && /\/api\/v1\/reminders\/rem_[0-9A-Z]+\/done$/.test(req.url)) return send(200, { ...REM, status: 'done' });
+ if (req.method === 'GET' && req.url === '/api/v1/reminders') return send(200, { reminders: [REM] });
+ return send(404, { error: 'not found' });
+});
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+/** Vibe's answer in that chat, as the Dock reads it. */
+async function answer(slug, after) {
+ for (let i = 0; i < 80; i++) {
+ const r = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(slug) + '&after=' + after, { token: ann.token });
+ const v = (r.body.messages || []).find((m) => m.from === 'vibe');
+ if (v) return v;
+ await wait(50);
+ }
+ throw new Error('Vibe did not answer');
+}
+const ago = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().replace('T', ' ').slice(0, 19);
+
+t('boot', async () => {
+ await new Promise((r) => todayServer.listen(0, '127.0.0.1', r));
+ todayServer.unref();
+ const local = 'http://127.0.0.1:' + todayServer.address().port;
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat', CHAT_TODAY_INTERNAL_URL: local } });
+ aiClient = require('../server/ai/client');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ bot = h.addUser('bot', { subject: ids.newId('user'), apiScopes: ['rooms.read'] });
+ for (const u of [ann, bob, bot]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? (script.shift() || (() => say('Sure.')))() : { text: 'plain' } } }) } });
+});
+
+t('the reminder Vibe set is kept with its answer', async () => {
+ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set: **Stretch**, every 2 hours.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });
+ assert.strictEqual(asked.status, 201, asked.text);
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ assert.deepStrictEqual(a.results, [{
+ kind: 'reminder', action: 'set', id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders',
+ }], 'the chip says what Vibe did');
+ const chat = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(asked.body.chat.slug), { token: ann.token });
+ const mine = chat.body.messages.find((m) => m.id === asked.body.message.id);
+ assert.strictEqual(mine.results, undefined, 'the person\'s own message has no results');
+ const stored = await h.db.getDb().prepare('SELECT results FROM room_messages WHERE id = ?').get(a.id);
+ assert.deepStrictEqual(stored.results, a.results, 'the same list is kept with the message');
+});
+
+t('an answer with nothing to show carries no results', async () => {
+ script = [() => say('Hello!')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'hello', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ assert.strictEqual(a.results, undefined);
+});
+
+t('a plain "remind me …" the model only promised is a result too', async () => {
+ script = [() => say('Of course, I will remind you.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'remind me to stretch every 2 hours from 9 to 5', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ assert.deepStrictEqual((a.results || []).map((r) => [r.kind, r.action, r.title]), [['reminder', 'set', 'Stretch']], 'the net set it, so it is kept');
+});
+
+t('undo stops the reminder and is offered only once', async () => {
+ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ today.calls.length = 0;
+ const undone = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(undone.status, 200, undone.text);
+ assert.deepStrictEqual(undone.body.results, [{ kind: 'reminder', action: 'stopped', id: REM.id, title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders' }]);
+ assert.deepStrictEqual(undone.body.undone, { kind: 'reminder', id: REM.id, title: 'Stretch' });
+ const stopped = today.calls.filter((c) => c.url.endsWith('/done'));
+ assert.strictEqual(stopped.length, 1, 'the reminder was stopped at OpenVibe.Today');
+ assert.strictEqual(stopped[0].subject, ann.subject_id, 'as the person themself');
+ const again = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(again.status, 409, again.text);
+ assert.strictEqual(today.calls.filter((c) => c.url.endsWith('/done')).length, 1, 'and not stopped twice');
+ const chat = await h.http('GET', '/api/vibe/chat?slug=' + encodeURIComponent(asked.body.chat.slug), { token: ann.token });
+ const kept = chat.body.messages.find((m) => m.id === a.id);
+ assert.deepStrictEqual(kept.results[0].action, 'stopped', 'the chip reads as stopped, not as something to undo');
+});
+
+t('only the person themself may undo it', async () => {
+ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ const other = await h.http('POST', '/api/vibe/undo', { token: bob.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(other.status, 404, 'someone else\'s answer is not there to undo');
+ const api = await h.http('POST', '/api/vibe/undo', { token: bot.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(api.status, 403, 'an API token is not a person');
+ const nowhere = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: 999999, index: 0 } });
+ assert.strictEqual(nowhere.status, 404);
+});
+
+t('a result of a day and a half ago is gone for good', async () => {
+ script = [() => callTools(toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' })), () => say('Set.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Remind me to stretch every 2 hours', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ await h.db.getDb().prepare('UPDATE room_messages SET created_at = ? WHERE id = ?').run(ago(25), a.id);
+ const old = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(old.status, 410, old.text);
+ today.calls.length = 0;
+ assert.strictEqual(today.calls.length, 0, 'and nothing was stopped');
+});
+
+t('at most four results are kept with an answer', async () => {
+ script = [() => callTools(
+ toolCall('c1', 'set_reminder', { text: 'stretch every 2 hours from 9 to 5' }),
+ toolCall('c2', 'set_reminder', { text: 'walk every 3 hours from 9 to 5' }),
+ toolCall('c3', 'set_reminder', { text: 'water every 4 hours from 9 to 5' }),
+ toolCall('c4', 'set_reminder', { text: 'read every 5 hours from 9 to 5' }),
+ toolCall('c5', 'set_reminder', { text: 'rest every 6 hours from 9 to 5' })), () => say('All set.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'set me five reminders', fresh: true } });
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ assert.strictEqual((a.results || []).length, 4, 'four is enough for a chip row');
+});
+
+t('the Dock\'s own timer shape reads as a chip', async () => {
+ const results = require('../server/rooms/vibe-results');
+ assert.deepStrictEqual(results.ofTool('start_timer', { timer: { kind: 'focus', focus_min: 25, rounds: 4, started_at: '2026-11-01T10:00:00.000Z', ends_at: '2026-11-01T10:25:00.000Z' } }),
+ { kind: 'timer', action: 'set', title: '25 min focus' }, 'a focus block says how long it runs');
+ assert.deepStrictEqual(results.ofTool('start_timer', { timer: { kind: 'timer', minutes: 10, label: 'Coffee' } }),
+ { kind: 'timer', action: 'set', title: '10 min Coffee' }, 'a plain countdown says so too');
+ assert.deepStrictEqual(results.ofTool('stop_timer', { stopped: { kind: 'focus', focus_min: 25 } }),
+ { kind: 'timer', action: 'stopped', title: '25 min focus' });
+ assert.strictEqual(results.ofTool('start_timer', { error: 'no timer' }), null, 'a tool that changed nothing is no chip');
+ assert.strictEqual(results.ofTool('web_search', { ok: true }), null, 'and neither is something else Vibe did');
+});
+
+t('the timer Vibe started is kept with its answer, and undo stops it on the Dock', async () => {
+ const timer = require('../server/rooms/vibe-timer');
+ const realFetch = globalThis.fetch;
+ timer._setTokens({ getToken: async () => 'chat-network-token' });
+ globalThis.fetch = async (url, o = {}) => {
+ const s = String(url);
+ if (!s.includes('/dock/timer')) return realFetch(url, o);
+ net.calls.push({ method: o.method, body: o.body ? JSON.parse(o.body) : null, subject: o.headers && o.headers['X-OV-Subject'] });
+ return { ok: true, status: 200, json: async () => ({ state: { timer: net.timer }, counts: {}, sources_partial: false }) };
+ };
+ try {
+ script = [() => say('Sure, I will start that timer for you.')];
+ const asked = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'start a 25 minute focus timer', fresh: true } });
+ assert.strictEqual(asked.status, 201, asked.text);
+ const a = await answer(asked.body.chat.slug, asked.body.message.id);
+ assert.deepStrictEqual(a.results, [{ kind: 'timer', action: 'set', title: '25 min focus' }], 'the chip says what Vibe did');
+ assert.deepStrictEqual(net.calls[net.calls.length - 1].body, { minutes: 25, kind: 'focus', rounds: 4 }, 'the timer was asked for');
+ const undone = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(undone.status, 200, undone.text);
+ assert.deepStrictEqual(undone.body.results, [{ kind: 'timer', action: 'stopped', title: '25 min focus' }], 'the chip reads as stopped');
+ const cleared = net.calls.filter((c) => c.body && c.body.clear === true);
+ assert.strictEqual(cleared.length, 1, 'the timer was stopped at OpenVibe.Network');
+ assert.strictEqual(cleared[0].subject, ann.subject_id, 'as the person themself');
+ const again = await h.http('POST', '/api/vibe/undo', { token: ann.token, body: { message_id: a.id, index: 0 } });
+ assert.strictEqual(again.status, 409, again.text);
+ assert.strictEqual(net.calls.filter((c) => c.body && c.body.clear === true).length, 1, 'and not stopped twice');
+ } finally {
+ globalThis.fetch = realFetch;
+ timer._setTokens(null);
+ }
+});
+
+t('done', async () => { if (aiClient) aiClient._setClient(null); todayServer.close(); await h.close(); });
+
+t.run();