openvibers/openvibe.actor Commits

Actor v2 stage V2-5: the task page shows the plan, the evidence behind each claim, the files and the check (#194)

OP OpenVibers committed 14 days ago · 2edd9c2661 · 7 files, +366 −10

- The plan card: each sub-goal done, in hand or next, and what the agent
  assumed; it follows the plan live while a task runs and heads How it went
  once it has ended. In the session each plan update is one divider naming
  the step in hand ("Step 2 of 4 · Read the pages"), never a repeat.
- The answer's citations ([E3], [E3, E5]) are numbered chips that open what
  that tool returned: the Evidence fold lists each item (the tool by name,
  what it was asked, what it returned, its sources, when), cited ones
  marked. Without scripts a chip is a link to the same item.
- One calm line: "Checked: 9 claims, 9 grounded · by DeepSeek Flash,
  another model family".
- Files are cards, a chart drawn small; tables keep whole words and scroll
  inside themselves on a phone.
- The cost against the budget, and the time it took.
- Plan updates no longer split the no-script view into one attempt each.
- make_chart's file names lose the runs of dashes a title leaves.
- The new styles are css/task.css, loaded by the task page only, so the
  home stays within its budget.

Changes

ddiff --git a/public/css/task.css b/public/css/task.css
nnew file mode 100644
iindex 0000000..6c60264
---- /dev/null
++++ b/public/css/task.css
@@@ -0,0 +1,69 @@
++/* A task's own page (Actor v2, stage V2-5), loaded by that page only so the home stays light: the plan, the evidence
++ chips and what they open, the check line, the files. The rest of the page is actor.css. */
++.plan-card { margin: 0 0 1.4rem; padding: 14px 18px 12px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.plan-card[hidden] { display: none; }
++.how .plan-card { margin: .2rem 0 1rem; }
++.plan-card > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.plan { list-style: none; margin: 0; padding: 0; display: grid; }
++.ps { position: relative; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; padding: 5px 0; color: var(--text-secondary, #96a7c2); line-height: 1.45; animation: step-in .25s ease-out; }
++.ps:not(:last-child)::before { content: ""; position: absolute; left: 9px; top: 26px; bottom: -6px; width: 2px; border-radius: 2px; background: var(--border, #1f2d47); }
++.ps.is-done:not(:last-child)::before { background: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); }
++.ps-mark { position: relative; width: 20px; height: 20px; margin-top: 1px; border-radius: 50%; box-sizing: border-box; border: 2px solid var(--border-light, #2c3d5c); background: var(--bg-card, #131c2e); }
++.ps.is-done { color: var(--text-primary, #e6edf7); }
++.ps.is-done .ps-mark { border-color: var(--success, #22c55e); background: var(--success, #22c55e); }
++.ps.is-done .ps-mark::after { content: ""; position: absolute; left: 5px; top: 2px; width: 5px; height: 9px; border: solid var(--bg-card, #131c2e); border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); }
++.ps.is-active { color: var(--text-primary, #e6edf7); font-weight: 650; }
++.ps.is-active .ps-mark { border-color: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); border-top-color: var(--accent, #3b82f6); animation: ps-spin .9s linear infinite; }
++.state-succeeded .ps.is-active .ps-mark, .state-failed .ps.is-active .ps-mark, .state-cancelled .ps.is-active .ps-mark { animation: none; border-color: var(--accent, #3b82f6); }
++.plan-assume { margin: .4rem 0 0 32px; font-size: .85rem; color: var(--text-muted, #7386a3); }
++.plan-assume[hidden] { display: none; }
++@keyframes ps-spin { to { transform: rotate(360deg); } }
++.tl-plan .tl-k { color: var(--accent-light, #60a5fa); }
++
++/* An answer's citations: a numbered chip per piece of evidence, opening what that tool returned. */
++.evs { white-space: nowrap; }
++.ev { display: inline-grid; place-items: center; min-width: 1.45em; height: 1.45em; padding: 0 .32em; margin: 0 .1em; box-sizing: border-box; border-radius: 7px;
++ font-size: .7em; font-weight: 800; line-height: 1; vertical-align: .3em; font-variant-numeric: tabular-nums; text-decoration: none;
++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); transition: background .15s; }
++.ev:hover, .ev:focus-visible, .ev.is-on { background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); color: var(--text-primary, #e6edf7); }
++.checked-line { display: flex; align-items: center; gap: 8px; margin: 1.1rem 0 0; font-size: .9rem; color: var(--success-text, var(--success, #22c55e)); }
++.checked-line svg { flex: none; width: 18px; height: 18px; padding: 2px; border-radius: 50%; box-sizing: border-box; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
++ background: color-mix(in srgb, var(--success, #22c55e) 16%, transparent); }
++
++/* The files a task made: a card each, a chart drawn small, any other file its type. */
++.files h3 { font-size: .8rem; letter-spacing: .5px; text-transform: uppercase; color: var(--text-muted, #7386a3); margin: 1.1rem 0 .5rem; }
++.file-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); gap: 10px; }
++.file-card { display: grid; grid-template-rows: auto auto; min-width: 0; overflow: hidden; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); }
++.fc-preview { display: grid; place-items: center; height: 116px; padding: 8px; box-sizing: border-box; overflow: hidden; background: var(--bg-card, #131c2e); border-bottom: 1px solid var(--border, #1f2d47); }
++.fc-preview img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100px; object-fit: contain; border-radius: 6px; }
++.fc-ext { display: grid; place-items: center; width: 54px; height: 64px; border-radius: 8px; font-size: .78rem; font-weight: 800; letter-spacing: .05em; color: var(--accent-light, #60a5fa);
++ background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
++.fc-meta { display: grid; gap: 1px; padding: 9px 12px 10px; min-width: 0; font-size: .85rem; }
++.fc-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
++.fc-name:hover { text-decoration: underline; }
++
++/* What the tools returned, behind the chips. */
++.evidence { margin: 1.1rem 0 0; border-top: 1px solid var(--border, #1f2d47); padding-top: .8rem; }
++.evidence > summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; cursor: pointer; font-weight: 700; font-size: .92rem; }
++.evidence > summary .muted { font-weight: 400; font-size: .85rem; }
++.ev-list { list-style: none; margin: .7rem 0 0; padding: 0; display: grid; gap: 8px; }
++.ev-item { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); scroll-margin-top: 80px; transition: border-color .3s, box-shadow .3s; }
++.ev-item:target, .ev-item.is-flash { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.ev-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; font-size: .85rem; }
++.ev-n { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; box-sizing: border-box; border-radius: 7px; font-size: .75rem; font-variant-numeric: tabular-nums;
++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
++.ev-item:not(.is-cited) .ev-n { color: var(--text-muted, #7386a3); background: var(--bg-card, #131c2e); }
++.ev-tool { font-weight: 700; }
++.ev-input { min-width: 0; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.ev-at { margin-left: auto; color: var(--text-muted, #7386a3); font-size: .8rem; font-variant-numeric: tabular-nums; }
++.ev-text { margin: .45rem 0 0; font: .8rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-secondary, #96a7c2); white-space: pre-wrap; overflow-wrap: anywhere;
++ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6; overflow: hidden; }
++.ev-item:target .ev-text, .ev-item.is-flash .ev-text, .ev-item.is-open .ev-text { -webkit-line-clamp: unset; display: block; }
++.ev-src { margin: .45rem 0 0; padding-left: 18px; font-size: .82rem; overflow-wrap: anywhere; }
++.answer-foot span:nth-child(2) { color: var(--text-muted, #7386a3); }
++
++/* A table keeps its words whole and scrolls inside itself on a narrow screen; the page never scrolls sideways. */
++.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }
++.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }
++
++@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }
ddiff --git a/public/js/task-events.js b/public/js/task-events.js
iindex b627bac..230629f 100644
---- a/public/js/task-events.js
++++ b/public/js/task-events.js
@@@ -6,8 +6,10 @@
* - every call gets an id, and its result (with the time between them, where both carry one) lands on it;
* - identical consecutive calls (same tool, same input) fold into one line with a count ("×2");
* - a task that has ended settles any call it never answered as done, with "No result was recorded for this step."
-- * - the run's real time is the task's own: first event → last event, on the done line.
-- * Pure (no DOM), so test/task-events.test.js can prove each rule.
++ * - the run's real time is the task's own: first event → last event, on the done line;
++ * - a plan the loop states becomes one divider naming the step in hand ("Step 2 of 4 · Read the pages"), so the
++ * calls under it read as that step's (Actor v2, stage V2-5); plan(text) reads the plan back for the plan card.
++ * Pure (no DOM), so test/task-events.test.js can prove each rule. The server requires it too (server/http/pages.js).
*/
(function (root, factory) {
'use strict';
@@@ -69,9 +71,32 @@
* time); opts.state is its final state (the done line is ok only when "succeeded"); opts.durationMs overrides the
* computed time.
*/
++ // A plan as the loop states it (server/runtime/loop.js planText): an optional "Assumptions: a; b" line, then one line
++ // a step, "\u2713 1. goal" (done), "\u2192 2. goal" (in hand) or "\u00b7 3. goal" (next). → { assumptions, steps } or null.
++ var PLAN_LINE = /^([\u2713\u2192\u00b7]) (\d+)\. (.+)$/;
++ function plan(text) {
++ var out = { assumptions: [], steps: [] };
++ String(text || '').split('\n').forEach(function (raw) {
++ var line = raw.trim(), m = PLAN_LINE.exec(line);
++ if (m) out.steps.push({ goal: m[3], state: m[1] === '\u2713' ? 'done' : m[1] === '\u2192' ? 'active' : 'todo' });
++ else if (/^Assumptions: /.test(line)) out.assumptions = line.slice(13).split('; ').filter(Boolean);
++ });
++ return out.steps.length ? out : null;
++ }
++ /** The divider a plan draws in the session: the step in hand, or the whole plan done. */
++ function planLine(p) {
++ if (!p || !p.steps.length) return null;
++ var at = -1;
++ p.steps.forEach(function (st, k) { if (at < 0 && st.state === 'active') at = k; });
++ if (at >= 0) return 'Step ' + (at + 1) + ' of ' + p.steps.length + ' \u00b7 ' + p.steps[at].goal;
++ var done = p.steps.every(function (st) { return st.state === 'done'; });
++ return done ? 'Every step of the plan is done' : 'A plan of ' + p.steps.length + ' steps';
++ }
++
function lines(events, opts) {
opts = opts || {};
var out = [];
++ var lastPlan = null; // the last plan divider, so a plan restated with the same step in hand draws nothing
var open = {}; // tool name → calls of that tool still waiting for their result
var last = null; // the last call, for folding an identical next one
var folded = null; // the result of a folded call, which must not become a step of its own
@@@ -133,7 +158,12 @@
out.push(withAgent(e.is_error ? { t: 'note', level: 'error', text: text } : { t: 'text', text: text }, e));
continue;
}
-- if (e.step === 'plan') { out.push(withAgent({ t: 'step', text: text }, e)); continue; }
++ if (e.step === 'plan') {
++ var stated = plan(text), divider = stated ? planLine(stated) : text;
++ if (divider && divider !== lastPlan) out.push(withAgent({ t: 'step', text: divider }, e));
++ lastPlan = divider;
++ continue;
++ }
if (e.step === 'handoff') { out.push(withAgent({ t: 'handoff', from: null, to: e.agent || null, reason: text }, e)); continue; }
if (e.step === 'check') { out.push(withAgent({ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text: text }, e)); continue; }
if (text) out.push(withAgent({ t: 'text', text: text }, e));
@@@ -150,5 +180,5 @@
return out;
}
-- return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments };
++ return { lines: lines, firstAt: firstAt, duration: duration, attachments: attachments, plan: plan, planLine: planLine };
});
ddiff --git a/public/js/task.js b/public/js/task.js
iindex d0412e7..d0379c3 100644
---- a/public/js/task.js
++++ b/public/js/task.js
@@@ -2,7 +2,9 @@
* call and its result), each agent that tried, its checks and hand-offs, from the events the page carries and, while the
* task runs, from its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1). Without it, a running task's steps
* are drawn the way the server renders a finished one. When the task ends the page reloads to show the outcome. Without
-- * this script the page refreshes itself every few seconds (<noscript>). */
++ * this script the page refreshes itself every few seconds (<noscript>).
++ * The task page v2 (stage V2-5): the plan card follows the plan as the agent states and revises it, and a citation chip
++ * in the answer opens the evidence behind it (without this script the chip is a link to the same place). */
(function () {
'use strict';
var LABEL = { tool_call: 'Used', tool_result: 'Got', text: 'Note' };
@@@ -41,7 +43,55 @@
return view;
}
++ // The plan card: drawn again from each plan the agent states (task-events.js reads it back), DOM nodes only.
++ var PLAN_STATE = { done: 'done', active: 'working on it', todo: 'next' };
++ function paintPlan(text) {
++ var card = document.getElementById('plan-card');
++ var p = card && window.OpenVibeTaskEvents ? window.OpenVibeTaskEvents.plan(text) : null;
++ if (!p) return;
++ var list = document.getElementById('plan-steps');
++ while (list.firstChild) list.removeChild(list.firstChild);
++ p.steps.forEach(function (st) {
++ var li = el('li', 'ps is-' + (PLAN_STATE[st.state] ? st.state : 'todo'));
++ var mark = el('span', 'ps-mark'); mark.setAttribute('aria-hidden', 'true');
++ li.appendChild(mark);
++ li.appendChild(el('span', 'ps-goal', st.goal));
++ li.appendChild(el('span', 'sr-only', ' (' + (PLAN_STATE[st.state] || 'next') + ')'));
++ list.appendChild(li);
++ });
++ var assume = document.getElementById('plan-assume');
++ if (assume) { assume.textContent = p.assumptions.length ? 'Assuming ' + p.assumptions.join(' \u00b7 ') : ''; assume.hidden = !p.assumptions.length; }
++ card.hidden = false;
++ }
++
++ // A citation chip opens the Evidence fold at its item, scrolls to it and marks it a moment; the URL keeps the item.
++ function evidence() {
++ if (document.documentElement.dataset.evBound) return;
++ document.documentElement.dataset.evBound = '1';
++ document.addEventListener('click', function (e) {
++ var chip = e.target.closest ? e.target.closest('a.ev[data-ev]') : null;
++ if (!chip) return;
++ var item = document.getElementById('ev-' + chip.getAttribute('data-ev'));
++ var fold = document.getElementById('evidence');
++ if (!item || !fold) return;
++ e.preventDefault();
++ fold.open = true;
++ document.querySelectorAll('.ev-item.is-flash').forEach(function (n) { n.classList.remove('is-flash'); });
++ document.querySelectorAll('a.ev.is-on').forEach(function (n) { n.classList.remove('is-on'); });
++ item.classList.add('is-flash', 'is-open');
++ chip.classList.add('is-on');
++ try { history.replaceState(null, '', '#ev-' + chip.getAttribute('data-ev')); } catch (err) { /* a sandboxed frame */ }
++ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ item.scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'center' });
++ setTimeout(function () { item.classList.remove('is-flash'); chip.classList.remove('is-on'); }, 2400);
++ });
++ // Opened at an item (a shared link, or the back button): the fold opens there too.
++ var at = /^#ev-E\d{1,3}$/.test(location.hash) ? document.getElementById(location.hash.slice(1)) : null;
++ if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }
++ }
++
function start() {
++ evidence();
var art0 = document.querySelector('article.task');
var view = art0 ? session(art0) : null;
var art = document.querySelector('article.task[data-open="1"]');
@@@ -129,6 +179,7 @@
function onOutput(e) {
onDesk(e);
onLook(e);
++ if (e.step === 'plan') paintPlan(e.text);
if (view) { view.push(attach(e)); return undefined; }
if ((e.step === 'plan' || e.step === 'handoff') && e.agent) return attempt((e.text || e.agent).replace(/ takes it.*$/, ''));
if (e.step === 'plan') return undefined;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex dcd68cb..d6f60f5 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -27,6 +27,7 @@ const looks = require('../agents/looks');
const { MODES } = require('../agents/router');
const seo = require('openvibe-shared/seo');
const { findArtifact, sendArtifact } = require('../runtime/artifacts');
++const taskEvents = require('../../public/js/task-events');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -84,6 +85,57 @@ const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.mode
const NEEDS = new Map(TEMPLATES.flatMap((g) => (g.needs ? g.items.map((t) => [t.text, g.needs]) : [])));
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
++
++// ── The task page v2 (Actor v2, stage V2-5): the plan, the evidence behind each claim, the files, the check, honestly ──
++// What a tool is called on the page; an unknown one reads as its own name with spaces.
++const TOOL_LABEL = {
++ search_web: 'Web search', read_page: 'Read a page', search_openvibe: 'OpenVibe search', wikipedia: 'Wikipedia',
++ weather_now: 'Weather', market_quote: 'Market price', currency_convert: 'Currency', time_in: 'Local time',
++ convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',
++ calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',
++ codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',
++};
++const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
++// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
++const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
++function modelName(id) {
++ const m = String(id || '').split(':').pop();
++ if (!m) return 'another model';
++ if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
++ return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
++}
++const ago = (ms) => {
++ const s = Math.max(0, Math.round(ms / 1000));
++ return s < 60 ? `${s} s` : s < 3600 ? `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}` : `${Math.floor(s / 3600)} h ${Math.round((s % 3600) / 60)} min`;
++};
++/** What a tool was asked, in a few words: its query, address or need, else its input as JSON. */
++function inputText(input) {
++ if (input == null) return '';
++ if (typeof input !== 'object') return String(input).slice(0, 160);
++ const v = input.query || input.url || input.need || input.place || input.symbol || input.expression || input.name || input.title;
++ if (v != null) return String(v).slice(0, 160);
++ // Otherwise its plain fields, as words: "to USD · from EUR · amount 7.6".
++ const plain = Object.entries(input).filter(([, x]) => x != null && typeof x !== 'object').map(([k, x]) => `${k.replace(/_/g, ' ')} ${x}`);
++ return (plain.length ? plain.join(' · ') : JSON.stringify(input)).slice(0, 160);
++}
++/**
++ * The answer's citations as chips: "[E3]" (or "[E3, E5]") becomes a numbered chip that opens what that tool returned,
++ * for the ids the ledger has; nothing inside code changes, and an id the ledger does not have stays as written.
++ */
++function chipify(htmlText, evidence) {
++ const known = new Map((evidence || []).map((e) => [String(e.id), e]));
++ if (!known.size) return htmlText;
++ const chip = (id) => {
++ const e = known.get(id);
++ const n = id.slice(1);
++ const label = `Evidence ${n}: ${toolLabel(e.tool)}${inputText(e.input) ? `, ${inputText(e.input)}` : ''}`.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
++ return `<a class="ev" href="#ev-${id}" data-ev="${id}" aria-label="${label}" title="${label}">${n}</a>`;
++ };
++ return String(htmlText).split(/(<pre[\s\S]*?<\/pre>|<code[\s\S]*?<\/code>)/).map((part, i) => (i % 2 ? part : part.replace(/\[(E\d{1,3}(?:\s*,\s*E\d{1,3})*)\]/g, (all, ids) => {
++ const list = ids.split(/\s*,\s*/);
++ return list.every((id) => known.has(id)) ? `<span class="evs">${list.map(chip).join('')}</span>` : all;
++ }))).join('');
++}
const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);
@@@ -346,6 +398,9 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const desk = open ? store.deskFrom(events) : null;
const look = looks.get(row.id);
++ const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
++ const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)
++ || (planEvent ? taskEvents.plan(planEvent.text) : null);
page(req, res, {
title: row.task.length > 60 ? `${row.task.slice(0, 59)}…` : row.task,
crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
@@@ -354,6 +409,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
scripts: ['js/task-events.js', 'js/task.js'],
sharedScripts: ['agent-session.js'],
styles: [showcase.STYLESHEET],
++ css: ['css/task.css'],
noReferrer: true,
body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
@@@ -362,6 +418,7 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
<h1 class="task-text">${row.task}</h1>
</header>
${outcome(t, row)}
++${open ? planCard(plan) : ''}
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>
@@@ -371,6 +428,7 @@ ${open || look ? html`<figure class="last-look" id="last-look"${look ? '' : raw(
<figcaption class="muted small">What your agent last saw on the screen${look ? html` · ${time(new Date(look.at).toISOString())}` : ''}</figcaption>
</figure>` : ''}
<section class="how" aria-labelledby="h-how"><h2 id="h-how">How it went</h2>
++${!open && plan ? planCard(plan) : ''}
<div class="attempts" id="attempts">${attemptsOf(events).map(attemptBlock)}</div>
<script type="application/json" id="task-events">${raw(JSON.stringify(events.slice(-3000)).replace(/</g, '\\u003c'))}</script>
${open ? html`<p class="muted small live-note" id="live-note"><span class="dot-pulse" aria-hidden="true"></span> Updating as the agent works.</p>` : ''}
@@@ -392,6 +450,20 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
]))}</details>`;
}
++ /**
++ * The plan the agent works through: each sub-goal done, in hand or next, and what it assumed. A live task's card is
++ * there before the first plan (hidden), so js/task.js can fill it as the plan arrives and changes.
++ */
++ function planCard(plan) {
++ const STATE = { done: 'done', active: 'working on it', todo: 'next' };
++ const steps = plan ? plan.steps : [];
++ return html`<section class="plan-card" id="plan-card" aria-labelledby="h-plan"${steps.length ? '' : raw(' hidden')}>
++<h2 id="h-plan">The plan</h2>
++<ol class="plan" id="plan-steps">${steps.map((st) => html`<li class="ps is-${STATE[st.state] ? st.state : 'todo'}"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">${st.goal}</span><span class="sr-only"> (${STATE[st.state] || 'next'})</span></li>`)}</ol>
++<p class="plan-assume" id="plan-assume"${plan && plan.assumptions.length ? '' : raw(' hidden')}>Assuming ${plan ? plan.assumptions.join(' · ') : ''}</p>
++</section>`;
++ }
++
/** The outcome card: the first thing under the task. */
function outcome(t, row) {
if (t.state === 'succeeded') return resultBlock(t.result, t);
@@@ -420,11 +492,26 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
const checked = result.checked || {};
const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
++ const evidence = Array.isArray(result.evidence) ? result.evidence.filter((e) => e && /^E\d{1,3}$/.test(String(e.id))) : [];
++ const cited = new Set(Array.isArray(result.citations) ? result.citations.map(String) : []);
++ const took = t.finished_at && t.created_at ? Date.parse(t.finished_at) - Date.parse(t.created_at) : NaN;
++ const claims = Number(checked.claims) || 0;
++ const grounded = Math.min(claims, Number(checked.grounded) || 0);
++ const by = `${modelName(checked.by)}${checked.cross_family ? ', another model family' : ', the same family'}`;
++ const ext = (a) => (String(a.name || '').split('.').pop() || a.kind || 'file').slice(0, 5).toUpperCase();
return html`<section class="outcome outcome-ok answer" aria-labelledby="h-answer"><h2 id="h-answer" class="sr-only">Answer</h2>
--<div class="prose">${raw(markdown(result.answer, { headingOffset: 2 }))}</div>
--${artifacts.length ? html`<div class="sources artifacts"><h3>Files</h3><ul>${artifacts.map((a) => html`<li><a href="${a.url}" rel="nofollow noopener">${a.name}</a> <span class="muted">${a.kind} · ${bytes(a.size)}</span>${a.mime === 'image/svg+xml' ? html`<div><img src="${a.url}" alt="${a.name}" loading="lazy" style="max-width:100%;height:auto;margin-top:8px"></div>` : ''}</li>`)}</ul></div>` : ''}
++<div class="prose">${raw(chipify(markdown(result.answer, { headingOffset: 2 }), evidence))}</div>
++<p class="checked-line"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span>${claims ? `Checked: ${claims} claim${claims === 1 ? '' : 's'}, ${grounded} grounded` : 'Checked'} · by ${by}</span></p>
++${artifacts.length ? html`<div class="files"><h3>Files</h3><ul class="file-grid">${artifacts.map((a) => html`<li class="file-card">
++<a class="fc-preview" href="${a.url}" rel="nofollow noopener" tabindex="-1" aria-hidden="true">${/^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) ? html`<img src="${a.url}" alt="" loading="lazy">` : html`<span class="fc-ext">${ext(a)}</span>`}</a>
++<span class="fc-meta"><a class="fc-name" href="${a.url}" rel="nofollow noopener">${a.name}</a><span class="muted">${a.kind === 'chart' ? 'Chart' : 'File'} · ${bytes(a.size)}</span></span></li>`)}</ul></div>` : ''}
${result.sources && result.sources.length ? html`<div class="sources"><h3>Sources</h3><ul>${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u.replace(/^https?:\/\//, '').slice(0, 90)}</a></li>`)}</ul></div>` : ''}
--<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · <code>${result.model}</code>` : ''}</span><span>✓ checked by ${checked.by}${checked.cross_family ? ' (another model family)' : ' (same family)'}</span><span>${t.cost ? usd(t.cost.usd) : '$0'}</span></p>
++${evidence.length ? html`<details class="evidence" id="evidence"><summary><span>Evidence</span><span class="muted">${evidence.length} item${evidence.length === 1 ? '' : 's'}: what the tools returned${cited.size ? `, ${cited.size} cited` : ''}</span></summary>
++<ol class="ev-list">${evidence.map((e) => html`<li class="ev-item${cited.has(String(e.id)) ? ' is-cited' : ''}" id="ev-${e.id}">
++<p class="ev-head"><b class="ev-n">${String(e.id).slice(1)}</b><span class="ev-tool">${toolLabel(e.tool)}</span>${inputText(e.input) ? html`<span class="ev-input">${inputText(e.input)}</span>` : ''}${e.at ? html`<time class="ev-at" datetime="${e.at}">${String(e.at).slice(11, 16)} UTC</time>` : ''}</p>
++${e.text ? html`<p class="ev-text">${String(e.text).slice(0, 700)}${String(e.text).length > 700 ? '…' : ''}</p>` : ''}
++${Array.isArray(e.sources) && e.sources.length ? html`<ul class="ev-src">${e.sources.slice(0, 6).map((src) => html`<li><a href="${src.url}" rel="nofollow ugc noopener" target="_blank">${src.title || String(src.url).replace(/^https?:\/\//, '').slice(0, 80)}</a></li>`)}</ul>` : ''}</li>`)}</ol></details>` : ''}
++<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · ${modelName(result.model)}` : ''}</span><span>${t.cost ? usd(t.cost.usd) : '$0'} of ${usd(t.budget.per_task_usd)}</span>${Number.isFinite(took) && took > 0 ? html`<span>took ${ago(took)}</span>` : ''}</p>
</section>`;
}
@@@ -439,7 +526,7 @@ ${result.sources && result.sources.length ? html`<div class="sources"><h3>Source
let lastCheckFailed = false;
for (const e of events) {
if (e.kind !== 'output') continue;
-- if ((e.step === 'plan' || e.step === 'handoff') && e.agent) {
++ if ((e.step === 'plan' || e.step === 'handoff') && e.agent && !(e.step === 'plan' && taskEvents.plan(e.text))) {
cur = { agent: e.agent, why: e.text, steps: [], refused: null, check: null, stopped: null };
out.push(cur);
lastCheckFailed = false;
@@@ -484,6 +571,7 @@ ${a.refused ? html`<details class="refused"><summary>See the answer that didn't
}
function stepItem(e) {
++ if (e.step === 'plan') { const p = taskEvents.plan(e.text); return html`<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">${p ? taskEvents.planLine(p) : e.text}</span></li>`; }
if (e.step === 'tool_call') return html`<li class="tl-item tl-tool"><span class="tl-k">Used</span><span class="tl-v"><code>${e.tool}</code> ${e.input || ''}</span></li>`;
if (e.step === 'tool_result') return html`<li class="tl-item tl-result${e.is_error ? ' tl-err' : ''}"><span class="tl-k">Got</span><span class="tl-v"><span class="muted">${(e.text || '').slice(0, 220)}</span></span></li>`;
if (/^Needs you: /.test(e.text || '')) return html`<li class="tl-item tl-needs"><span class="tl-k">Needs you</span><span class="tl-v">${withDeskLinks(String(e.text).replace(/^Needs you: /, ''))}</span></li>`;
ddiff --git a/server/runtime/tools/make_chart.js b/server/runtime/tools/make_chart.js
iindex d1e1f3e..1389929 100644
---- a/server/runtime/tools/make_chart.js
++++ b/server/runtime/tools/make_chart.js
@@@ -15,6 +15,7 @@ function slugName(raw) {
const base = String(raw || 'chart')
.replace(/[\\/]+/g, '-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
++ .replace(/-{2,}/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.replace(/\.[A-Za-z0-9]{1,8}$/, '')
.slice(0, 100);
ddiff --git a/test/task-events.test.js b/test/task-events.test.js
iindex 891823d..10a748d 100644
---- a/test/task-events.test.js
++++ b/test/task-events.test.js
@@@ -3,7 +3,7 @@
// task never answered settles as done instead of spinning forever, identical consecutive calls fold into one line
// with a count, and the run's time is the task's own (first event → last event).
const assert = require('assert');
--const { lines, firstAt, duration } = require('../public/js/task-events');
++const { lines, firstAt, duration, plan, planLine } = require('../public/js/task-events');
const at = (s) => `2026-10-30T10:00:${s}.000Z`;
const call = (input, s, extra) => ({ kind: 'output', step: 'tool_call', tool: 'web_search', input, at: at(s), agent: 'openai-agent', ...extra });
@@@ -82,4 +82,19 @@ const end = (state, s) => ({ kind: 'end', state, at: at(s) });
assert.strictEqual(duration([], { durationMs: 2500 }), 2500);
}
++// The plan the loop states (Actor v2, stage V2-5): read back exactly, a divider per step in hand, never a repeat.
++{
++ const stated = (k) => ['Assumptions: under $10; Europe', ...['Find three offers', 'Read the pages', 'Compare them'].map((g, i) => `${i < k ? '\u2713' : i === k ? '\u2192' : '\u00b7'} ${i + 1}. ${g}`)].join('\n');
++ assert.deepStrictEqual(plan(stated(1)), { assumptions: ['under $10', 'Europe'], steps: [
++ { goal: 'Find three offers', state: 'done' }, { goal: 'Read the pages', state: 'active' }, { goal: 'Compare them', state: 'todo' }] });
++ assert.strictEqual(plan('This is a "research" task.'), null, 'the engine\'s own words are not a plan');
++ assert.strictEqual(planLine(plan(stated(1))), 'Step 2 of 3 \u00b7 Read the pages');
++ assert.strictEqual(planLine(plan(stated(3))), 'Every step of the plan is done');
++ const ev = (t, sec) => ({ kind: 'output', step: 'plan', text: t, at: at(sec), agent: 'openvibe-runtime' });
++ const out = lines([ev(stated(0), '01'), call('offers', '02'), ev(stated(0), '03'), ev(stated(1), '04'), call('pages', '05'), ev(stated(3), '06')], { final: false });
++ assert.deepStrictEqual(out.filter((l) => l.t === 'step').map((l) => l.text), ['Step 1 of 3 \u00b7 Find three offers', 'Step 2 of 3 \u00b7 Read the pages', 'Every step of the plan is done'],
++ 'one divider per step in hand: the same plan stated twice draws nothing new');
++ assert.deepStrictEqual(lines([{ kind: 'output', step: 'plan', text: 'The agent starts with a plan, then works through it.', at: at('01') }]).map((l) => l.text), ['The agent starts with a plan, then works through it.'], 'other plan words stay as they are');
++}
++
console.log('task events: all checks passed');
ddiff --git a/test/task-page-v2.test.js b/test/task-page-v2.test.js
nnew file mode 100644
iindex 0000000..074413a
---- /dev/null
++++ b/test/task-page-v2.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++// The task page v2 (Actor v2, stage V2-5): the answer's citations are chips that open what the tool returned, one calm
++// line says how many claims were checked and by whom, the files are cards (a chart drawn small), the plan shows each
++// step done, in hand or next (and a live task has the card ready for the plan to arrive), the cost is against the
++// budget with the time it took, and the plan updates never split the no-script view into one attempt each.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ t.config.loop.selfReflections = 0;
++ const s = t.ctx.s;
++ const { appendEvent } = require('../server/tasks/store');
++ const at = (sec) => new Date(Date.now() - sec * 1000).toISOString();
++
++ t.providers.state.script = [
++ { content: 'Charting it.', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'make_chart', arguments: JSON.stringify({ type: 'bar', title: 'Price -- per month', data: [{ label: 'A', value: 4.5 }, { label: 'B', value: 6.7 }] }) } }] },
++ { content: 'done' },
++ ];
++ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Compare two offers and chart them' }, headers: SAME })).json();
++ await t.waitFor(created.id);
++ const row = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [created.id]);
++ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result;
++ result.answer = 'A is cheapest at €4.51 [E1], B is €6.74 [E2, E1]. Not a citation: `[E1]` and [E9].';
++ result.evidence = [
++ { id: 'E1', tool: 'read_page', input: { url: 'https://a.example/pricing' }, text: 'A · €4.51/month', sources: [{ url: 'https://a.example/pricing', title: 'A pricing' }], at: at(60) },
++ { id: 'E2', tool: 'search_web', input: { query: 'b price <script>' }, text: 'B · €6.74', sources: [], at: at(50) },
++ { id: 'E3', tool: 'calculate', input: { expression: '6.74-4.51' }, text: '2.23', sources: [], at: at(40) },
++ ];
++ result.citations = ['E1', 'E2'];
++ result.checked = { by: 'deepseek:deepseek-flash', cross_family: true, reason: 'ok', claims: 4, grounded: 4 };
++ result.plan = { assumptions: ['euro prices'], steps: [{ goal: 'Read both pages', state: 'done' }, { goal: 'Chart them', state: 'done' }] };
++ await s.db.query('UPDATE tasks SET result = $2::jsonb, cost_usd = 0.0124, created_at = $3, finished_at = $4 WHERE id = $1', [created.id, JSON.stringify(result), at(90), at(4)]);
++ const stated = ['\u2713 1. Read both pages', '\u2192 2. Chart them'].join('\n');
++ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated, at: at(30) });
++ await appendEvent(s, created.id, { kind: 'output', step: 'plan', agent: result.agent, text: stated.replace('\u2192', '\u2713'), at: at(20) });
++
++ try {
++ await check('citations become chips for the evidence the ledger has, and nothing else changes', async () => {
++ const r = await t.get(`/tasks/${created.id}`, { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<a class="ev" href="#ev-E1" data-ev="E1"'), 'a chip for E1');
++ assert.ok(/<span class="evs"><a class="ev" href="#ev-E2"[^>]*>2<\/a><a class="ev" href="#ev-E1"[^>]*>1<\/a><\/span>/.test(r.text), '[E2, E1] is two chips, in that order');
++ assert.ok(r.text.includes('<code>[E1]</code>'), 'a citation inside code stays code');
++ assert.ok(r.text.includes('[E9]'), 'an id the ledger does not have stays as written');
++ assert.ok(r.text.includes('aria-label="Evidence 2: Web search, b price &#60;script&#62;"'), 'the chip says what it opens, escaped');
++ assert.ok(!r.text.includes('<script>"'), 'nothing from a tool input is markup');
++ });
++
++ await check('the evidence behind the chips: each item, what it was asked, what it returned, its sources', async () => {
++ const r = await t.get(`/tasks/${created.id}`, { as: kim });
++ assert.ok(r.text.includes('<details class="evidence" id="evidence">'), 'one fold of evidence');
++ assert.ok(r.text.includes('3 items: what the tools returned, 2 cited'));
++ assert.ok(r.text.includes('<li class="ev-item is-cited" id="ev-E1">') && r.text.includes('<li class="ev-item" id="ev-E3">'), 'cited and uncited marked');
++ assert.ok(r.text.includes('<span class="ev-tool">Read a page</span>') && r.text.includes('https://a.example/pricing'), 'the tool by name, what it read');
++ assert.ok(r.text.includes('>A pricing</a>'), 'its sources');
++ });
++
++ await check('one calm check line, files as cards, the cost against the budget and the time it took', async () => {
++ const r = await t.get(`/tasks/${created.id}`, { as: kim });
++ assert.ok(r.text.includes('Checked: 4 claims, 4 grounded · by DeepSeek Flash, another model family'));
++ assert.ok(/<li class="file-card">[\s\S]*?<img src="[^"]+\/files\/art_[^"]+"/.test(r.text), 'the chart is drawn small on its card');
++ assert.ok(r.text.includes('Price-per-month.svg'), 'its name, without the runs of dashes a title leaves');
++ assert.ok(r.text.includes('<span>$0.01 of $0.05</span>') && /took 1 min 2\d s/.test(r.text), 'the cost against the budget and the time');
++ });
++
++ await check('the plan: every step and what it assumed, at the head of How it went once the task has ended', async () => {
++ const r = await t.get(`/tasks/${created.id}`, { as: kim });
++ const how = r.text.slice(r.text.indexOf('id="h-how"'));
++ assert.ok(how.includes('<section class="plan-card" id="plan-card"'), 'the plan card is under How it went');
++ assert.ok(how.includes('<li class="ps is-done"><span class="ps-mark" aria-hidden="true"></span><span class="ps-goal">Read both pages</span>'));
++ assert.ok(how.includes('Assuming euro prices'));
++ assert.strictEqual((r.text.match(/<section class="attempt"/g) || []).length, 1, 'plan updates do not split the no-script view into attempts');
++ assert.ok(r.text.includes('<li class="tl-item tl-plan"><span class="tl-k">Plan</span><span class="tl-v">Step 2 of 2 · Chart them</span>'), 'a plan update is a step of its attempt');
++ });
++
++ await check('a live task has the plan card ready, and it shows the plan as the agent states it', async () => {
++ const live = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Count to three' }, headers: SAME })).json();
++ await t.waitFor(live.id);
++ await s.db.query("UPDATE tasks SET state = 'running', result = NULL, finished_at = NULL WHERE id = $1", [live.id]);
++ let r = await t.get(`/tasks/${live.id}`, { as: kim });
++ assert.ok(/<section class="plan-card" id="plan-card" aria-labelledby="h-plan"( hidden)?>/.test(r.text), 'the card is there for js/task.js to fill');
++ await appendEvent(s, live.id, { kind: 'output', step: 'plan', agent: 'openvibe-runtime', text: '\u2192 1. Count\n\u00b7 2. Say it', at: at(1) });
++ r = await t.get(`/tasks/${live.id}`, { as: kim });
++ const head = r.text.slice(0, r.text.indexOf('id="h-how"'));
++ assert.ok(head.includes('<li class="ps is-active">') && head.includes('<li class="ps is-todo">'), 'above How it went while it runs: in hand, then next');
++ });
++
++ await check('another person still gets the 404 of a task that does not exist', async () => {
++ const r = await t.get(`/tasks/${created.id}`, { as: bo });
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('ev-item'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });