openvibers/openvibe.chat Commits

Chat: what Vibe really did, kept with its answer, and an undo for it (#290)

OP OpenVibers committed 13 days ago · e085a564d5 · 6 files, +437 −8

* Chat: keep what Vibe really did with its answer, and let the person undo it

Vibe's answer was plain text, so the Dock's Vibe tab and Chat's own /vibe page could only show what
Vibe said, never what it did: the reminder it set, the timer it started.

What Vibe really did is now kept with the answer, in a small JSON list on the message
(room_messages.results, a nullable jsonb column; server/rooms/vibe-results.js): at most four items,
each { kind, action, id?, title, when?, url? } — the reminder's id, title, when and the page where it
is managed, or the timer's minutes and kind. It is written where the answer is posted, from the
tool calls (set_reminder, stop_reminder, start_timer, stop_timer) and from the deterministic net
that sets a reminder even when the model only promised it, and it goes with the message.

GET /api/vibe/chat carries the list on each Vibe message (absent when there is none), so the chips
can be drawn from it.

POST /api/vibe/undo { message_id, index } undoes one of them: the reminder is stopped on
OpenVibe.Today, the timer on the Dock, and the result is marked undone (its action becomes
'stopped') so it is not offered again. The person themself only, in their own chat, only what Vibe
set, and only within a day of the answer: someone else's answer is 404, an API token 403, a second
undo 409, an old one 410.

* Chat: read a timer in the Dock's own shape, set or stopped

* Vibe can start the person's Dock timer

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.

* Merge the Dock's timer tools (feature/l48-vibe-timer)

* Chat: keep the timer Vibe started with its answer too, and undo it

The timer tools and the deterministic net that sets a timer (feature/l48-vibe-timer, merged) are
now part of Vibe's turn, so what it did with the timer is kept with the answer as well: the chip
says "25 min focus", and undoing it stops the timer in the Dock, through the same tool Vibe itself
uses, so the Dock and Vibe keep one timer between them.

OpenVibe.Today and OpenVibe.Network stand in for the two services in the test.

* Chat: one blank line less before the undo route

* Chat: an answer keeps at most four results, and the test says so

Changes

ddiff --git a/migrations/0036_vibe_results.sql b/migrations/0036_vibe_results.sql
nnew file mode 100644
iindex 0000000..c4ffb76
---- /dev/null
++++ b/migrations/0036_vibe_results.sql
@@@ -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;
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex c479a7d..464a345 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -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);
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 889ada0..07625f5 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -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
ddiff --git a/server/rooms/vibe-api.js b/server/rooms/vibe-api.js
iindex beadaa3..a1c57a4 100644
---- a/server/rooms/vibe-api.js
++++ b/server/rooms/vibe-api.js
@@@ -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 asking
const 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) => {
ddiff --git a/server/rooms/vibe-results.js b/server/rooms/vibe-results.js
nnew file mode 100644
iindex 0000000..cb7cdad
---- /dev/null
++++ b/server/rooms/vibe-results.js
@@@ -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 };
ddiff --git a/test/vibe-results.test.js b/test/vibe-results.test.js
nnew file mode 100644
iindex 0000000..5a7cec2
---- /dev/null
++++ b/test/vibe-results.test.js
@@@ -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();