openvibers/openvibe.actor Commits

An agent's live canvas (HO9): canvas_set draws the data-only actor.canvas@1 panel beside the thread (#339)

OP OpenVibers committed 5 days ago · c8396b4e12 · 17 files, +814 −22

* openvibe-contracts v0.340.0 (was v0.328.0)

* An agent's live canvas (HO9): canvas_set draws the data-only actor.canvas@1 panel beside the thread; a button asks as the person, a tick tells the next turn

* Merge main into the canvas work

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* The canvas reason's own comment sits on the function that uses it

* Merge main (the contracts pin) into the canvas work

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* The canvas migration takes 0045, above the open HO2 and HO7 numbers (0043, 0044)

* Merge main (the skills registry) into the canvas work

# Conflicts:
#	server/db.js
#	server/http/api.js

* Merge main (the skills scan fix) into the canvas work

* The canvas on a phone: a closed 44 px bar that opens, says updated, and leaves the composer where it was (HO9 review)

* Merge main (task end ordering and goal counters) into the canvas work

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 84cdcd2..4fdff15 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,27 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
++## Unreleased — an agent's live canvas (HO9)
++
++- **An agent's live panel beside the conversation** (plan track HO, wave 2; ADR-059, `actor.canvas@1`): the agent calls
++ `canvas_set({ title, blocks })` and the panel is drawn beside its thread — a trip board, a price table, a checklist, a
++ small chart — from data only (no HTML, no style, no script). The whole document is replaced and its version grows;
++ it is validated against the released contract, and a picture outside openvibe.media, a markup block or another
++ block the contract does not know is refused with the schema's plain reason and nothing is written. One run may call
++ it at most 20 times. The panel lives in `actor_canvases`: one row per conversation (the thread's first turn), with
++ the document, its version and the ticks waiting for the next turn (migration 0045).
++- **The person's moves, never the canvas's**: a button's label is posted by the API as the person's own message — a
++ normal turn from the newest turn of the conversation, at the agent's level and through its approvals, exactly as
++ typing it would (nothing else happens). A checklist tick flips that item in the document (version + 1) and starts
++ no turn; the next turn is told which item the person ticked, once, as its conversation context. Both go through the
++ same-origin API (`GET /api/v1/me/canvases?thread=`, `GET /api/v1/me/canvases/:id`,
++ `POST /api/v1/me/canvases/:id/events`) and another person's canvas is the same 404 as one that does not exist.
++- **Where it shows**: the thread page draws it in the third column on a wide screen and as one closed "Canvas · N
++ items" bar above the composer on a phone (open on tap; the open body scrolls inside at 45vh), with openvibe-shared
++ v4.145.0's ov-canvas.js. The page redraws it from the thread's live events when `canvas_set` runs; a closed bar
++ still updates its count and says "updated" for a few seconds. The document is in the account export and goes with
++ the account. Test: `test/canvas.test.js`; openvibe-contracts v0.341.0 names the routes.
++
## Unreleased — the skills registry (plan HO2)
- **A skill is a document, and the registry vouches for it** (plan HO2, ADR-058; contracts 0.340.0): `actor.skill@1`
ddiff --git a/README.md b/README.md
iindex 83e5bc5..e7afee8 100644
---- a/README.md
++++ b/README.md
@@@ -261,7 +261,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.340.0
++- openvibe-contracts: v0.341.0
- openvibe-sdk: v0.96.0
--- openvibe-shared: v4.142.0
++- openvibe-shared: v4.145.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex f8e6c3c..49c8603 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,13 +7,13 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"domain": "openvibe.actor",
-- "contracts": "openvibe-contracts v0.340.0",
++ "contracts": "openvibe-contracts v0.341.0",
"sdk": "openvibe-sdk v0.96.0",
-- "shared": "openvibe-shared v4.142.0",
++ "shared": "openvibe-shared v4.145.0",
"packages": [
-- "openvibe-contracts v0.340.0",
++ "openvibe-contracts v0.341.0",
"openvibe-sdk v0.96.0",
-- "openvibe-shared v4.142.0"
++ "openvibe-shared v4.145.0"
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"works": [
@@@ -34,7 +34,8 @@
"Task webhooks on state changes: up to 8 https endpoints per task, signed with the task's own secret (or a project secret by reference), retried with backoff, with a delivery log on the task page and public addresses only",
"Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): six space_* tools (create, list, read, write, preview, publish) work on the person's own spaces through OpenVibe.Space's builder API; another person's space is the same 404 as one that does not exist, and publishing waits for the person's answer in the Dock",
"Agents are contacts (A-1): a signed-in person's GET /api/v1/agents is their own roster, Vibe created on first read and never deletable, with a face, voice, model engine, computer, connected apps, Memory scope and approval level; GET /api/v1/agents/presets gives four presets to copy, and GET /api/v1/agent-systems keeps the public catalog for every caller",
-- "Approval levels (A-1): ask (the default), auto-edits, approve-for-me (a reviewer model of another family) and full; money, passwords, deletions and sending to a new recipient ask at every level as a server-side floor. Tasks record agent_id (default Vibe) and their own level; the effective level is the higher of the agent's and the person's own parent chain"
++ "Approval levels (A-1): ask (the default), auto-edits, approve-for-me (a reviewer model of another family) and full; money, passwords, deletions and sending to a new recipient ask at every level as a server-side floor. Tasks record agent_id (default Vibe) and their own level; the effective level is the higher of the agent's and the person's own parent chain",
++ "An agent's live canvas (HO9; actor.canvas@1): canvas_set draws a data-only panel beside the thread, one row per conversation, validated and versioned; a button asks as the person, a tick flips an item and tells the next turn, and another person's canvas is 404 (migration 0045)"
],
"notYet": [
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
ddiff --git a/migrations/0045_actor_canvases.sql b/migrations/0045_actor_canvases.sql
nnew file mode 100644
iindex 0000000..ab77492
---- /dev/null
++++ b/migrations/0045_actor_canvases.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- HO9 (plan track HO, wave 2): an agent's live panel beside the conversation.
++--
++-- A canvas is the data document actor.canvas@1 (Contracts v0.340.0, ADR-059) drawn by OpenVibe.Shared's
++-- ov-canvas.js. One row is one person's canvas for one thread (the first turn of a conversation, tsk_…): the
++-- agent's canvas_set replaces the whole document and the version only grows. NOTICES holds the person's ticks
++-- that the next turn has to be told about (a tick is not a new turn: the next run reads it as context and the
++-- row is cleared when it does).
++--
++-- Expand-only: a new table, nothing else changed. A release before HO9 never reads or writes it.
++CREATE TABLE actor_canvases (
++ id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ agent_id text COLLATE "C",
++ thread text COLLATE "C" NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
++ document jsonb NOT NULL,
++ version integer NOT NULL DEFAULT 1 CHECK (version >= 1),
++ notices jsonb NOT NULL DEFAULT '[]'::jsonb,
++ updated_at text COLLATE "C" NOT NULL,
++ UNIQUE (thread)
++);
++
++-- One person's canvases, newest first (the read behind /api/v1/me/canvases and the account export).
++CREATE INDEX actor_canvases_owner ON actor_canvases (owner, updated_at DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex b381cc0..f28c7c7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.340.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.341.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.145.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1146,9 +1146,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.340.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.340.0",
-- "integrity": "sha512-PqwI4NjDce+IuW8lrk3qXo839b5j7+ponCPqXYTAUUpS5mHq6+j7hGgNxUlgTmgHN98/ipNVdA5Wt/kdTlTUMA==",
++ "version": "0.341.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.341.0",
++ "integrity": "sha512-vdjT66+voXWBoi+cqWjSTVAelix8TyiGyKAFtqlFriscMdIStjHphHkQ/teTZoXdyevk6RWsPPgpBgs410kp0Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1190,9 +1190,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.142.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
-- "integrity": "sha512-C+h3qpRCN6Sgwd9qLsAICyzttiPlipk8p8hgaI0e2/gj3nt4/vLFmMxJsIoltYh413yJ4JwynrXAwfM0akSbmA==",
++ "version": "4.145.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.145.0",
++ "integrity": "sha512-JowfVhYvgv7imC7WoQo9sEVIFyD87NIA8QoVVcwoLGg3m3rlrFJDGNA0jHyTgSv65uqr7Cbja6oCwU36cVYiuw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex f9e0ff7..23c41ad 100644
---- a/package.json
++++ b/package.json
@@@ -28,9 +28,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.340.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.341.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.145.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 4cbc261..7f81730 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -174,3 +174,24 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@keyframes ct-breathe { 0%, 100% { opacity: .55; transform: scale(.94); } 50% { opacity: 1; transform: scale(1); } }
@keyframes ct-shimmer { from { background-position: 100% 0; } to { background-position: -50% 0; } }
@media (prefers-reduced-motion: reduce) { .ct-hello-mark, .ct-live::before { animation: none !important; } }
++
++/* ── The canvas (plan HO9, actor.canvas@1) ───────────────────────────────────────────────────────────────────────
++ The same data document in two places: the thread's third column on a wide screen (thread.css's own panel, which is
++ sticky from 1180 px), and a fold above the composer on a phone. Only one is shown; the two hold the same document
++ and js/chat.js paints both, so a tick redraws instantly. */
++.cc-canvas { min-width: 0; }
++.cc-canvas-count { flex: none; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }
++.cc-canvas-body, .cc-canvas-host { min-width: 0; }
++.cc-canvas-fallback { display: grid; gap: 2px; margin: 0; padding: 12px 14px; border-radius: 12px; background: var(--bg-card, #131c2e); border: 1px solid var(--line); }
++.cc-canvas-fallback b { font-size: 14px; overflow-wrap: anywhere; }
++.cc-canvas-fallback span { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
++.cc-canvas-phone { max-width: 780px; margin: 0 auto 8px; border: 1px solid var(--line); border-radius: 14px; background: var(--bg-card, #131c2e); overflow: hidden; contain: layout paint; }
++.cc-canvas-phone > summary { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; cursor: pointer; font-size: 14px; font-weight: 650; list-style: none; color: var(--text-primary, #e6edf7); }
++.cc-canvas-phone > summary::-webkit-details-marker { display: none; }
++.cc-canvas-phone > summary::marker { content: ''; }
++.cc-canvas-phone .cc-canvas-updated { flex: none; font-size: 12.5px; font-weight: 650; color: var(--accent, #4f7cff); }
++.cc-canvas-phone .cc-canvas-chev { margin-left: auto; color: var(--text-muted, #7386a3); transition: transform .2s; }
++.cc-canvas-phone[open] .cc-canvas-chev { transform: rotate(180deg); }
++.cc-canvas-phone .cc-canvas-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
++@media (min-width: 1180px) { .cc-canvas-phone { display: none !important; } }
++@media (max-width: 1179px) { .cc-canvas-wide { display: none !important; } }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 7b3cfac..6cdc46b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -32,6 +32,103 @@
var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ // ── The canvas (plan HO9). The document rides in an inert template; openvibe-shared's ov-canvas.js draws it
++ // into the wide panel and the phone fold from the same data. A button's label goes to the agent as the
++ // person's own message (POST .../events, kind ask); a tick is theirs too (kind check), and the redraw is the
++ // returned document. A canvas_set on the working turn's stream asks for the newest document.
++ var canvasThread = chat.getAttribute('data-chat') || '';
++ var canvasTimer = 0;
++ var canvasVersion = 0;
++ var canvasUpdatedTimer = 0;
++ function canvasDocOf(box) {
++ var tpl = box.querySelector('template[data-cc-canvas-doc]');
++ if (!tpl) return null;
++ try { return JSON.parse(tpl.content ? tpl.content.textContent : tpl.textContent); } catch (e) { return null; }
++ }
++ function canvasCount(n) {
++ var word = n + (n === 1 ? ' item' : ' items');
++ chat.querySelectorAll('[data-cc-canvas-count]').forEach(function (el) { el.textContent = word; });
++ }
++ function paintCanvas(doc) {
++ if (!doc || !window.OVCanvas) return;
++ chat.querySelectorAll('[data-cc-canvas]').forEach(function (box) {
++ var host = box.querySelector('[data-cc-canvas-host]');
++ if (host) window.OVCanvas.render(host, doc, {
++ onAsk: function (label) { canvasAsk(doc, label); },
++ onCheck: function (blockId, itemId, done) { canvasCheck(doc, blockId, itemId, done); },
++ });
++ });
++ canvasCount(Array.isArray(doc.blocks) ? doc.blocks.length : 0);
++ canvasVersion = Number(doc.version) || canvasVersion;
++ }
++ // A closed fold still says something changed: "updated" beside the count for a few seconds, text only.
++ function canvasUpdated() {
++ var el = chat.querySelector('.cc-canvas-phone [data-cc-canvas-updated]');
++ if (!el) return;
++ el.hidden = false;
++ if (canvasUpdatedTimer) clearTimeout(canvasUpdatedTimer);
++ canvasUpdatedTimer = setTimeout(function () { el.hidden = true; canvasUpdatedTimer = 0; }, 4000);
++ }
++ function canvasPost(doc, body) {
++ return fetch('/api/v1/me/canvases/' + encodeURIComponent(doc.id) + '/events', {
++ method: 'POST', credentials: 'same-origin',
++ headers: { accept: 'application/json', 'content-type': 'application/json' },
++ body: JSON.stringify(body),
++ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, j: j }; }); });
++ }
++ function canvasAsk(doc, label) {
++ if (!doc || !doc.id) return;
++ canvasPost(doc, { kind: 'ask', label: String(label == null ? '' : label) })
++ .then(function (res) {
++ if (!res.ok) { problem(res.j.detail || res.j.error || 'Actor could not take that just now.'); return; }
++ var task = res.j.task || {};
++ location.assign(task.url || ('/chat/' + (task.id || '')));
++ })
++ .catch(function () { problem('No connection. The message was not sent.'); });
++ }
++ function canvasCheck(doc, blockId, itemId, done) {
++ if (!doc || !doc.id) return;
++ canvasPost(doc, { kind: 'check', block_id: blockId, item_id: itemId, done: !!done })
++ .then(function (res) {
++ if (!res.ok) { problem(res.j.detail || res.j.error || 'That tick did not save.'); paintCanvas(doc); return; }
++ if (res.j.canvas) paintCanvas(res.j.canvas);
++ })
++ .catch(function () { problem('No connection. That tick did not save.'); paintCanvas(doc); });
++ }
++ function refreshCanvas() {
++ if (!canvasThread) return;
++ fetch('/api/v1/me/canvases?thread=' + encodeURIComponent(canvasThread), { credentials: 'same-origin', headers: { accept: 'application/json' } })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (j) {
++ var doc = j && Array.isArray(j.canvases) ? j.canvases[0] : null;
++ if (!doc) return;
++ // The first canvas was made while this page was open: the server renders the panel on a reload.
++ if (!chat.querySelector('[data-cc-canvas]')) { location.reload(); return; }
++ if (canvasVersion && Number(doc.version) > canvasVersion) canvasUpdated();
++ paintCanvas(doc);
++ })
++ .catch(function () {});
++ }
++ function canvasSoon() {
++ if (canvasTimer) return;
++ canvasTimer = setTimeout(function () { canvasTimer = 0; refreshCanvas(); }, 400);
++ }
++ (function () {
++ var first = null;
++ chat.querySelectorAll('[data-cc-canvas]').forEach(function (box) { if (!first) first = canvasDocOf(box); });
++ if (first) paintCanvas(first);
++ // Closed first (the bar is one 44 px line); the person's choice is kept per thread for this visit, so
++ // a reload does not reopen what they closed or close what they opened.
++ var folds = chat.querySelectorAll('.cc-canvas-phone');
++ folds.forEach(function (box) {
++ var key = 'ov-canvas-open:' + (box.getAttribute('data-thread') || canvasThread);
++ try { if (window.sessionStorage && sessionStorage.getItem(key) === '1') box.open = true; } catch (e) { /* private mode */ }
++ box.addEventListener('toggle', function () {
++ try { if (window.sessionStorage) sessionStorage.setItem(key, box.open ? '1' : '0'); } catch (e) { /* private mode */ }
++ });
++ });
++ })();
++
// ── The thread stays at the bottom while you are there.
function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
function toBottom(smooth) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }
@@@ -422,6 +519,7 @@
var e = JSON.parse(m.data);
var line = turn.querySelector('[data-ovt-live]');
if (line && e.step === 'tool_call' && e.tool) line.textContent = 'Using ' + String(e.tool).replace(/_/g, ' ') + '…';
++ if (e.tool === 'canvas_set') canvasSoon();
settleDraft(turn, e);
} catch (err) { /* a malformed event is skipped */ }
soon();
@@@ -430,7 +528,7 @@
try { addDelta(turn, JSON.parse(m.data)); } catch (err) { /* a malformed piece is skipped */ }
});
src.addEventListener('state', soon);
-- src.addEventListener('end', function () { src.close(); clearTimeout(timer); timer = 0; refresh(turn); });
++ src.addEventListener('end', function () { src.close(); clearTimeout(timer); timer = 0; canvasSoon(); refresh(turn); });
src.onerror = function () { if (src.readyState === 2) setTimeout(function () { refresh(turn); }, 3000); };
}
chat.querySelectorAll('.ovt-turn[data-open]').forEach(function (t) { follow(t); });
ddiff --git a/server/canvases.js b/server/canvases.js
nnew file mode 100644
iindex 0000000..c623710
---- /dev/null
++++ b/server/canvases.js
@@@ -0,0 +1,249 @@
++'use strict';
++
++/**
++ * An agent's live panel beside the conversation (plan HO9, actor.canvas@1, ADR-059).
++ *
++ * The document is DATA: a title and a list of blocks (heading, text, list, checklist, table, metric, chart, image,
++ * link, button), drawn by openvibe-shared's ov-canvas.js wherever it is shown. Actor stores one per thread (the
++ * conversation's first turn), validates every write against the released actor.canvas@1 schema, and refuses a
++ * document the schema does not know with the schema's plain reason. The agent replaces the whole document; the
++ * version only grows.
++ *
++ * A canvas never acts. A button's label is posted by the API as the person's own message (a normal turn), and a
++ * checklist tick changes the document here and is left in NOTICES for the next turn to read as context. Another
++ * person's canvas is the same 404 as one that does not exist.
++ */
++const contracts = require('openvibe-contracts');
++
++const CNV_ID = /^cnv_[0-9A-HJKMNP-TV-Z]{26}$/;
++const BLOCK_ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
++const AGENT_ID = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MAX_BLOCKS = 60;
++const MAX_TITLE = 120;
++// One run may call canvas_set at most this many times (the tool counts on its run's context).
++const MAX_SETS_PER_RUN = 20;
++
++const TYPES = ['heading', 'text', 'list', 'checklist', 'table', 'metric', 'chart', 'image', 'link', 'button'];
++const TYPE_NAMES = `${TYPES.slice(0, -1).join(', ')} or ${TYPES[TYPES.length - 1]}`;
++
++const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
++const itemsOf = (doc) => (doc && Array.isArray(doc.blocks) ? doc.blocks : []);
++
++/** The document a row holds, as data (the schema's own shape). */
++const docOf = (row) => (row ? parse(row.document) : null);
++
++/** How many items the page's fold says the canvas holds. */
++const countOf = (doc) => itemsOf(doc).length;
++
++function cleanTitle(value) {
++ const title = typeof value === 'string' ? value.trim() : '';
++ if (!title) return { error: 'title: a canvas needs a title.' };
++ if (title.length > MAX_TITLE) return { error: `title: at most ${MAX_TITLE} characters.` };
++ return { title };
++}
++
++// The fields each block kind really needs, so the oneOf's other branches ("a heading needs text" on an image
++// missing its alt) do not become the reason the agent reads.
++const REQUIRED = {
++ heading: ['text'], text: ['text'], list: ['items'], checklist: ['items'], table: ['columns', 'rows'],
++ metric: ['label', 'value'], chart: ['kind', 'series'], image: ['src', 'alt'], link: ['href', 'text'], button: ['label', 'action'],
++};
++
++/**
++ * The schema's first plain reason for a document we built. The oneOf over the block kinds makes a raw AJV error
++ * list unreadable ("must match exactly one schema in oneOf" ten times); the block's own type is enough to say
++ * what is wrong, so the messages are chosen from the error at or under that block.
++ */
++function reasonFor(errors, blocks) {
++ const list = Array.isArray(errors) ? errors : [];
++ const blockAt = (at) => {
++ const m = /^\/blocks\/(\d+)/.exec(String(at || ''));
++ return m ? blocks[Number(m[1])] : null;
++ };
++ const useful = list
++ .map((e) => ({ e, at: String(e.path || ''), message: String(e.message || '') }))
++ .filter((x) => !/^must be equal to constant$/.test(x.message) && !/^must match exactly one schema in oneOf$/.test(x.message))
++ .filter((x) => {
++ const required = /required property '([^']+)'/.exec(x.message);
++ if (!required) return true;
++ const block = blockAt(x.at);
++ return !block || !block.type || !REQUIRED[block.type] || REQUIRED[block.type].includes(required[1]);
++ });
++ const inBlock = useful.filter((x) => /^\/blocks\/\d+/.test(x.at));
++ // A required field the block kind really has is the reason; a branch-kind's leftover "additional property" is not.
++ const requiredPick = inBlock.find((x) => {
++ const m = /required property '([^']+)'/.exec(x.message);
++ const b = blockAt(x.at);
++ return Boolean(m && b && b.type && REQUIRED[b.type] && REQUIRED[b.type].includes(m[1]));
++ });
++ const error = requiredPick || inBlock.find((x) => !/additional propert/.test(x.message)) || inBlock[0] || useful[0] || list[0] || null;
++ if (!error) return 'the canvas does not match actor.canvas@1.';
++ const at = error.at;
++ const blockIndex = /^\/blocks\/(\d+)/.exec(at);
++ const block = blockIndex ? blocks[Number(blockIndex[1])] : null;
++ const where = blockIndex ? `block ${Number(blockIndex[1]) + 1}${block && block.type ? ` (${block.type})` : ''}: ` : '';
++ const message = error.message;
++ if (/additional property/.test(message)) return `${where}a canvas block has no such field.`;
++ const required = /required property '([^']+)'/.exec(message);
++ if (required) return `${where}${block && block.type ? block.type : 'the block'} needs "${required[1]}".`;
++ if (/must match pattern/.test(at) && /\/src$/.test(at)) return `${where}a picture must be on openvibe.media, and nowhere else.`;
++ if (/must match pattern/.test(at) && /\/href$/.test(at)) return `${where}a link must be https.`;
++ if (/must match pattern/.test(message) || /must match format/.test(message)) return `${where}${message.replace(/^must /, 'it must ')}.`;
++ if (/must NOT have more than|must NOT have fewer than|must NOT have more|must NOT have less/.test(message)) return `${where}${message.replace(/^must NOT /, 'it must not ')}.`;
++ if (/must be integer|must be number|must be string|must be boolean/.test(message)) return `${where}${message.replace(/^must be /, 'it must be ')}.`;
++ return `${where}${message}.`;
++}
++
++/** Every problem with a title and blocks, before the released schema sees them. */
++function precheck(title, blocks) {
++ if (!Array.isArray(blocks)) return 'blocks must be a list of canvas blocks.';
++ if (blocks.length > MAX_BLOCKS) return `blocks: at most ${MAX_BLOCKS} blocks in one canvas.`;
++ for (let i = 0; i < blocks.length; i++) {
++ const b = blocks[i];
++ const where = `block ${i + 1}`;
++ if (!b || typeof b !== 'object' || Array.isArray(b)) return `${where} is not a canvas block.`;
++ if (!TYPES.includes(b.type)) return `${where}: "${String(b.type || '')}" is not a canvas block (${TYPE_NAMES}).`;
++ if (b.type === 'image' && !/^https:\/\/openvibe\.media\//.test(String(b.src || ''))) return `${where} (image): a picture must be on openvibe.media, and nowhere else.`;
++ if (b.type === 'link' && !/^https:\/\//.test(String(b.href || ''))) return `${where} (link): a link must be https.`;
++ if (b.type === 'button' && b.action !== 'ask') return `${where} (button): a button can only ask ("action": "ask"); it never acts.`;
++ }
++ const t = cleanTitle(title);
++ if (t.error) return t.error;
++ return null;
++}
++
++/** Build the actor.canvas@1 document from a stored row and the new title/blocks. */
++function build({ id, title, blocks, version, agentId, thread, updatedAt }) {
++ const doc = { canvas: 'actor.canvas@1', id, title, version };
++ if (agentId && AGENT_ID.test(String(agentId))) doc.agent_id = String(agentId);
++ doc.thread = String(thread);
++ doc.updated_at = updatedAt;
++ doc.blocks = blocks;
++ return doc;
++}
++
++/**
++ * Write the whole document for a thread (canvas_set). A new thread gets version 1; an existing one gets its
++ * version + 1, its id and its agent when this run did not name one. → { row, doc } or { error }.
++ */
++async function put(s, { owner, agentId, thread, title, blocks }) {
++ if (!thread || !/^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(thread))) return { error: 'a canvas sits beside a conversation, and this run has none.' };
++ const bad = precheck(title, blocks);
++ if (bad) return { error: bad };
++ const clean = cleanTitle(title);
++ return s.tx(async () => {
++ // Two canvas_set calls in one turn run in parallel: serialize the first write of a thread, so one makes the
++ // row and the other updates it (the unique thread would otherwise turn the second into an error).
++ await s.db.query('SELECT pg_advisory_xact_lock(hashtext($1))', [String(thread)]);
++ const existing = await s.db.maybe('SELECT * FROM actor_canvases WHERE thread = $1 FOR UPDATE', [thread]);
++ if (existing && existing.owner !== owner) return { error: 'that conversation is not yours.' };
++ const id = existing ? existing.id : s.newId('cnv');
++ const version = existing ? Number(existing.version) + 1 : 1;
++ const updatedAt = s.iso();
++ const doc = build({ id, title: clean.title, blocks, version, agentId: agentId || (existing && existing.agent_id), thread, updatedAt });
++ const v = contracts.validate('actor.canvas@1', doc);
++ if (!v.valid) return { error: reasonFor(v.errors, blocks) };
++ if (existing) {
++ await s.db.query('UPDATE actor_canvases SET document = $2::jsonb, version = $3, agent_id = $4, updated_at = $5 WHERE id = $1',
++ [id, JSON.stringify(doc), version, doc.agent_id || null, updatedAt]);
++ } else {
++ await s.db.query('INSERT INTO actor_canvases (id, owner, agent_id, thread, document, version, notices, updated_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8)',
++ [id, owner, doc.agent_id || null, thread, JSON.stringify(doc), version, '[]', updatedAt]);
++ }
++ return { row: await s.db.maybe('SELECT * FROM actor_canvases WHERE id = $1', [id]), doc };
++ });
++}
++
++/** The canvas of one thread, if there is one. */
++const forThread = (s, thread) => s.db.maybe('SELECT * FROM actor_canvases WHERE thread = $1', [String(thread)]);
++
++/** One person's canvas by id; another person's is null (the caller answers 404). */
++const get = (s, owner, id) => (CNV_ID.test(String(id)) ? s.db.maybe('SELECT * FROM actor_canvases WHERE id = $1 AND owner = $2', [String(id), owner]) : null);
++
++/** A person's canvases, newest first (the API list and the account export). */
++const list = (s, owner, limit = 50) => s.db.many('SELECT * FROM actor_canvases WHERE owner = $1 ORDER BY updated_at DESC LIMIT $2', [owner, limit]);
++
++/** The first turn of the conversation a task belongs to (a fork starts its own). One recursive query. */
++async function threadForTask(s, taskId) {
++ if (!taskId) return null;
++ const row = await s.db.maybe(`WITH RECURSIVE up AS (
++ SELECT id, parent_id, fork_of_step, 0 AS depth FROM tasks WHERE id = $1
++ UNION ALL
++ SELECT t.id, t.parent_id, t.fork_of_step, up.depth + 1
++ FROM up JOIN tasks t ON t.id = up.parent_id
++ WHERE up.fork_of_step IS NULL AND up.depth < 200)
++ SELECT id FROM up ORDER BY depth DESC LIMIT 1`, [String(taskId)]);
++ return row ? row.id : null;
++}
++
++/** The newest turn of a conversation (the parent of a message typed into it today). One recursive query. */
++async function latestForThread(s, thread) {
++ if (!thread) return null;
++ return s.db.maybe(`WITH RECURSIVE down AS (
++ SELECT id, parent_id, fork_of_step, 0 AS depth FROM tasks WHERE id = $1
++ UNION ALL
++ SELECT n.id, n.parent_id, n.fork_of_step, down.depth + 1
++ FROM down
++ CROSS JOIN LATERAL (
++ SELECT t.id, t.parent_id, t.fork_of_step FROM tasks t
++ WHERE t.parent_id = down.id AND t.fork_of_step IS NULL
++ ORDER BY t.id DESC LIMIT 1) n
++ WHERE down.depth < 200)
++ SELECT * FROM down ORDER BY depth DESC LIMIT 1`, [String(thread)]);
++}
++
++/**
++ * The ticks the agent has not been told about yet, as conversation context, and clear them. The next run of the
++ * conversation reads them once. → [{ task, answer }] (empty when there is nothing).
++ */
++async function takeNoticesForTask(s, task) {
++ if (!task || !task.id) return [];
++ const thread = await threadForTask(s, task.id);
++ if (!thread) return [];
++ return s.tx(async () => {
++ const row = await s.db.maybe('SELECT * FROM actor_canvases WHERE thread = $1 FOR UPDATE', [thread]);
++ if (!row) return [];
++ const notices = parse(row.notices) || [];
++ if (!Array.isArray(notices) || !notices.length) return [];
++ await s.db.query("UPDATE actor_canvases SET notices = '[]'::jsonb WHERE id = $1", [row.id]);
++ const title = (docOf(row) || {}).title || 'the canvas';
++ return notices.slice(0, 50).map((n) => ({
++ task: `Canvas "${title}"`,
++ answer: `The person ${n.done ? 'ticked' : 'unticked'} "${n.text}" (block ${n.block_id}, item ${n.item_id}).`,
++ }));
++ });
++}
++
++/**
++ * Tick or untick one checklist item (POST /api/v1/me/canvases/:id/events, kind "check"). The whole document is
++ * rewritten with the item's done flag changed and its version + 1; the tick waits in NOTICES for the next turn.
++ * → { row, doc } or { error: { status, detail } }.
++ */
++async function check(s, owner, id, { blockId, itemId, done }) {
++ if (!BLOCK_ID.test(String(blockId || '')) || !BLOCK_ID.test(String(itemId || ''))) {
++ return { error: { status: 422, detail: 'block_id and item_id are the ids the canvas document names.' } };
++ }
++ return s.tx(async () => {
++ const row = await s.db.maybe('SELECT * FROM actor_canvases WHERE id = $1 AND owner = $2 FOR UPDATE', [String(id), owner]);
++ if (!row) return { error: { status: 404, detail: 'No such canvas.' } };
++ const doc = docOf(row);
++ const block = itemsOf(doc).find((b) => b && b.type === 'checklist' && b.id === blockId);
++ const item = block && Array.isArray(block.items) ? block.items.find((it) => it && it.id === itemId) : null;
++ if (!item) return { error: { status: 422, detail: 'That checklist item is not on the canvas any more.' } };
++ const value = done === true;
++ item.done = value;
++ const version = Number(row.version) + 1;
++ doc.version = version;
++ doc.updated_at = s.iso();
++ const notices = parse(row.notices) || [];
++ notices.push({ block_id: blockId, item_id: itemId, text: String(item.text || ''), done: value, at: s.iso() });
++ await s.db.query('UPDATE actor_canvases SET document = $2::jsonb, version = $3, notices = $4::jsonb, updated_at = $5 WHERE id = $1',
++ [row.id, JSON.stringify(doc), version, JSON.stringify(notices.slice(-50)), doc.updated_at]);
++ return { doc, row: { ...row, version, document: doc, updated_at: doc.updated_at } };
++ });
++}
++
++module.exports = {
++ CNV_ID, BLOCK_ID, TYPES, MAX_BLOCKS, MAX_SETS_PER_RUN,
++ put, get, list, forThread, threadForTask, latestForThread, takeNoticesForTask, check, docOf, countOf, reasonFor, build,
++};
ddiff --git a/server/db.js b/server/db.js
iindex 9b54935..3beed74 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_skill_listings', 'actor_skill_installs'];
++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex bfc8eee..a565c6c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -45,6 +45,7 @@ const timeline = require('../agents/timeline');
const assignments = require('../agents/assignments');
const modelPrefs = require('../runtime/model-prefs');
const driving = require('../agents/driving');
++const canvases = require('../canvases');
const skills = require('../skills');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -662,6 +663,72 @@ function createApi(ctx) {
res.json(result);
});
++ // ── An agent's live panel (plan HO9, actor.canvas@1) ─────
++ // The person's own canvas for one conversation. Reading is actor.task.read; drawing or ticking is the person's
++ // action in their thread, so it takes actor.task.create. Another person's canvas is the same 404 as one that does
++ // not exist. A page may post a plain form (same-origin); JSON callers get JSON.
++ const canvasWantsJson = (req) => String(req.get('accept') || '').includes('application/json') || String(req.get('content-type') || '').includes('application/json');
++ r.get('/me/canvases', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), personOnly, async (req, res) => {
++ const requester = req.principal.requester;
++ if (req.query.thread != null) {
++ const thread = String(req.query.thread);
++ if (!TASK_ID.test(thread)) return problem(req, res, 400, 'actor.canvas.invalid', 'thread must be a tsk_… conversation id.');
++ const row = await canvases.forThread(s, thread);
++ return res.json({ canvases: row && row.owner === requester ? [canvases.docOf(row)] : [] });
++ }
++ const rows = await canvases.list(s, requester);
++ res.json({ canvases: rows.map(canvases.docOf) });
++ });
++ r.get('/me/canvases/:id', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), personOnly, async (req, res) => {
++ const row = await canvases.get(s, req.principal.requester, req.params.id);
++ if (!row) return problem(req, res, 404, 'actor.canvas.not_found', 'No such canvas.');
++ res.json(canvases.docOf(row));
++ });
++ // A canvas never acts. "ask" is the button's label, posted as the person's own message: a normal turn from the
++ // newest turn of the conversation, with the agent's level and approvals exactly as typing it would. "check" is
++ // the person's own tick: the document's item flips, its version grows, and the next turn is told which item.
++ r.post('/me/canvases/:id/events', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), personOnly,
++ express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
++ const wantsJson = canvasWantsJson(req);
++ const row = await canvases.get(s, req.principal.requester, req.params.id);
++ if (!row) return wantsJson ? problem(req, res, 404, 'actor.canvas.not_found', 'No such canvas.') : res.status(404).type('text/plain').send('No such canvas.');
++ const body = req.body || {};
++ const kind = String(body.kind || '');
++ if (kind === 'ask') {
++ const label = String(body.label == null ? '' : body.label).trim().slice(0, 2000);
++ if (!label) return wantsJson ? problem(req, res, 422, 'actor.canvas.invalid', 'A button needs a label to send.') : res.status(422).type('text/plain').send('A button needs a label to send.');
++ const latest = await canvases.latestForThread(s, row.thread);
++ if (!latest) return wantsJson ? problem(req, res, 409, 'actor.canvas.invalid', 'That conversation is not here any more.') : res.status(409).type('text/plain').send('That conversation is not here any more.');
++ if (store.OPEN.includes(latest.state) || latest.state === 'needs_you') {
++ return wantsJson ? problem(req, res, 409, 'actor.canvas.invalid', 'Actor is still working on the last message. Wait for it, or stop it.') : res.status(409).type('text/plain').send('Actor is still working on the last message.');
++ }
++ const out = await createTask({
++ s, config, engine, principal: { requester: req.principal.requester, project: null },
++ body: { task: label, mode: latest.mode }, parentId: latest.id,
++ personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null,
++ });
++ if (out.code) {
++ if (!wantsJson) return res.status(out.status === 503 || out.status === 429 ? out.status : 422).type('text/plain').send(out.detail || 'That could not start.');
++ return problem(req, res, out.status === 503 || out.status === 429 ? out.status : 422, out.code, out.detail || 'That could not start.');
++ }
++ const made = await store.getTask(s, out.task.id);
++ if (!wantsJson) return res.redirect(303, `/chat/${made.id}#t-${made.id}`);
++ return res.status(201).json({ ok: true, task: { id: made.id, url: `/chat/${made.id}` } });
++ }
++ if (kind === 'check') {
++ const done = body.done === true || body.done === 'true' || body.done === '1' || body.done === 'on';
++ const out = await canvases.check(s, req.principal.requester, row.id, { blockId: body.block_id, itemId: body.item_id, done });
++ if (out.error) {
++ return wantsJson ? problem(req, res, out.error.status || 422, 'actor.canvas.invalid', out.error.detail) : res.status(out.error.status || 422).type('text/plain').send(out.error.detail);
++ }
++ if (!wantsJson) return res.redirect(303, `/chat/${row.thread}`);
++ return res.json({ ok: true, canvas: out.doc });
++ }
++ return wantsJson
++ ? problem(req, res, 422, 'actor.canvas.invalid', 'kind must be "ask" (a button label) or "check" (block_id, item_id, done).')
++ : res.status(422).type('text/plain').send('kind must be "ask" or "check".');
++ });
++
// ── Triggers made in a conversation (plan §12 A-4) ───────
// The Watch subscription is the runner; these routes are the card's own moves. Everything is the person's:
// another person's trigger is the same 404 as one that does not exist, and Watch receives their own token.
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 57f6413..c6bfc55 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -33,6 +33,7 @@ const looks = require('../agents/looks');
const inputs = require('../tasks/inputs');
const modelPrefs = require('../runtime/model-prefs');
const aiCatalog = require('../runtime/ai-catalog');
++const canvases = require('../canvases');
// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
@@@ -420,6 +421,33 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
}
++ /**
++ * The canvas (plan HO9): the data document actor.canvas@1, drawn by openvibe-shared's ov-canvas.js. The wide
++ * layout puts it in the thread's third column; a phone gets the same document as a fold directly above the
++ * composer. The document rides in a template (inert, never executed), so the page has it before the script runs,
++ * and the fallback says what it is before that. For somebody a run was shared with there is no canvas: it
++ * belongs to the conversation's owner, not to one shared turn.
++ */
++ const canvasItems = (n) => `${n} item${n === 1 ? '' : 's'}`;
++ const canvasJson = (doc) => raw(JSON.stringify(doc).replace(/</g, '\\u003c').replace(/&/g, '\\u0026'));
++ function canvasFallback(doc) {
++ return html`<p class="cc-canvas-fallback"><b>${doc.title}</b><span>${canvasItems(canvases.countOf(doc))}. This panel is drawn on the page.</span></p>`;
++ }
++ function canvasWide(doc) {
++ const n = canvases.countOf(doc);
++ return html`<aside class="ovt-panel cc-canvas cc-canvas-wide" aria-label="Canvas" data-cc-canvas data-thread="${doc.thread}">
++<div class="ovt-panel-head"><h2>Canvas</h2><span class="cc-canvas-count" data-cc-canvas-count>${canvasItems(n)}</span></div>
++<div class="cc-canvas-body"><div class="cc-canvas-host" data-cc-canvas-host>${canvasFallback(doc)}</div>
++<template data-cc-canvas-doc>${canvasJson(doc)}</template></div></aside>`;
++ }
++ function canvasPhone(doc) {
++ const n = canvases.countOf(doc);
++ return html`<details class="cc-canvas cc-canvas-phone" data-cc-canvas data-thread="${doc.thread}">
++<summary><span class="cc-canvas-label">Canvas</span><span class="cc-canvas-sep" aria-hidden="true">·</span><span class="cc-canvas-count" data-cc-canvas-count>${canvasItems(n)}</span><span class="cc-canvas-updated" data-cc-canvas-updated aria-live="polite" hidden>updated</span><span class="cc-canvas-chev" aria-hidden="true">▾</span></summary>
++<div class="cc-canvas-body"><div class="cc-canvas-host" data-cc-canvas-host>${canvasFallback(doc)}</div>
++<template data-cc-canvas-doc>${canvasJson(doc)}</template></div></details>`;
++ }
++
async function turnHtml(row, { events = null, files = null, readOnly = false } = {}) {
const attached = files || (await inputs.list(s, [row.id])).get(row.id) || [];
const t = store.toWire(row, { baseUrl: config.baseUrl });
@@@ -553,6 +581,8 @@ ${modelChip(model)}
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
++ const canvasRow = row && !shared && first ? await canvases.forThread(s, first.id) : null;
++ const canvasDoc = canvasRow ? canvases.docOf(canvasRow) : null;
const model = shared ? null : modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));
const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));
const title = first ? clip(first.task, 60) : 'New chat';
@@@ -562,7 +592,7 @@ ${modelChip(model)}
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
// The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).
css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css'],
-- sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js'],
++ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js', 'ov-canvas.js'],
body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
@@@ -570,7 +600,8 @@ ${modelChip(model)}
turns: turnHtmls.map(String),
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
-- composer: shared ? '' : String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),
++ composer: shared ? '' : String(html`${canvasDoc ? canvasPhone(canvasDoc) : ''}${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })}`),
++ panel: canvasDoc ? String(canvasWide(canvasDoc)) : '',
label: first ? 'Conversation' : 'New chat',
}))}${defaults}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex b3c8866..2506648 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -50,6 +50,9 @@ const TABLES = [
// state. Before tasks, so the rows are counted before a task's cascade could take them.
{ table: 'actor_assignments', subject: 'owner', value: (usr) => `user:${usr}`, file: 'assignments.json',
columns: ['id', 'owner', 'root', 'parent', 'depth', 'origin', 'mode', 'reply', 'agent_id', 'task_id', 'level', 'effective_level', 'budget_usd', 'budget_spent_usd', 'state', 'created_at', 'updated_at', 'started_at', 'ended_at'] },
++ // The agent's live panel beside a conversation (plan HO9): the document it drew, the version and the ticks
++ // waiting for the next turn. The thread is the person's own first turn; the canvas goes with them.
++ { table: 'actor_canvases', subject: 'owner', value: (usr) => `user:${usr}`, file: 'canvases.json', columns: ['id', 'owner', 'agent_id', 'thread', 'document', 'version', 'notices', 'updated_at'] },
// Every column but webhook_secret (a task's signing secret is a credential, not the person's data).
{
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
ddiff --git a/server/runtime/tools/canvas_set.js b/server/runtime/tools/canvas_set.js
nnew file mode 100644
iindex 0000000..4492575
---- /dev/null
++++ b/server/runtime/tools/canvas_set.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++/**
++ * canvas_set: draw the agent's live panel beside this conversation (plan HO9, actor.canvas@1, ADR-059).
++ *
++ * The whole document is replaced and its version grows; the released contract validates it, and a document it does
++ * not know is refused with the plain reason (never half-written). A canvas only draws: a button asks the person's
++ * agent for them and a checklist tick is the person's own (server/canvases.js). One run may make at most 20 calls.
++ *
++ * Blocks (every one needs "type"; an "id" is a stable slug, a-z0-9_- up to 40 characters):
++ * {type:"heading", text, level?:1|2} {type:"text", text} {type:"list", items:[string], ordered?}
++ * {type:"checklist", id, items:[{id, text, done}]}
++ * {type:"table", columns:[string], rows:[[string|number]]} {type:"metric", label, value, note?}
++ * {type:"chart", kind:"bar"|"line", unit?, series:[{name, points:[{x, y}]}]}
++ * {type:"image", src:"https://openvibe.media/…", alt} {type:"link", href:"https://…", text}
++ * {type:"button", label, action:"ask"}
++ */
++const canvases = require('../../canvases');
++
++module.exports = {
++ name: 'canvas_set',
++ description: 'Draw the live panel beside this conversation for the person: replace its whole document with a new '
++ + 'title and blocks, and the version grows. Use it when the work has a shape worth keeping beside the chat (a '
++ + 'trip plan, a price table, a checklist, a small chart), and update it as the work moves. Blocks are data, '
++ + 'never HTML: heading {text, level?}, text {text}, list {items, ordered?}, checklist {id, items:[{id, text, done}]}, '
++ + 'table {columns, rows}, metric {label, value, note?}, chart {kind:"bar"|"line", unit?, series:[{name, points:[{x, y}]}]}, '
++ + 'image {src (openvibe.media only), alt}, link {href (https), text}, button {label, action:"ask"}. A button '
++ + 'only sends its label as the person\'s own message; a checklist tick is theirs. Give stable ids to a checklist '
++ + 'and its items so a tick still means the same item after an update. At most 60 blocks, 8 table columns and '
++ + '100 rows; use at most 20 canvas_set calls in one run.',
++ input: {
++ type: 'object',
++ properties: {
++ title: { type: 'string', minLength: 1, maxLength: 120, description: 'The panel\'s title, e.g. "Lisbon, 12–14 March".' },
++ blocks: {
++ type: 'array',
++ maxItems: 60,
++ description: 'The whole document, in order. Each block is an object with a "type" (see the tool description).',
++ items: { type: 'object', additionalProperties: true },
++ },
++ },
++ required: ['title', 'blocks'],
++ additionalProperties: false,
++ },
++ scope: 'person',
++ acts: false,
++ costUsd: 0,
++ timeoutMs: 5000,
++ available: (ctx) => Boolean(ctx && ctx.store && ctx.taskId),
++
++ async run(input, ctx) {
++ if (!ctx.requester || !String(ctx.requester).startsWith('user:')) {
++ return { text: JSON.stringify({ error: 'a canvas belongs to a signed-in person; this task has none' }), isError: true };
++ }
++ if (!ctx.taskId) {
++ return { text: JSON.stringify({ error: 'a canvas sits beside a conversation; this run has none' }), isError: true };
++ }
++ const used = Number(ctx.canvasWrites) || 0;
++ if (used >= canvases.MAX_SETS_PER_RUN) {
++ return { text: JSON.stringify({ error: `at most ${canvases.MAX_SETS_PER_RUN} canvas_set calls in one run; say what is already there instead` }), isError: true };
++ }
++ ctx.canvasWrites = used + 1;
++ const thread = await canvases.threadForTask(ctx.store, ctx.taskId);
++ if (!thread) return { text: JSON.stringify({ error: 'that conversation is not stored, so there is nowhere to draw' }), isError: true };
++ const out = await canvases.put(ctx.store, {
++ owner: ctx.requester,
++ agentId: ctx.agentId,
++ thread,
++ title: input && input.title,
++ blocks: input && input.blocks,
++ });
++ if (out.error) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const doc = out.doc;
++ return {
++ text: `Canvas "${doc.title}" is now version ${doc.version} with ${doc.blocks.length} block${doc.blocks.length === 1 ? '' : 's'}; it is drawn beside this conversation.`,
++ data: { canvas: doc },
++ };
++ },
++};
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 14e236d..c342f08 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -39,6 +39,7 @@ const memory = require('../memory');
const taskNotices = require('../notify/task-notices');
const confidence = require('../runtime/brain/confidence');
const learn = require('../runtime/brain/learn');
++const canvases = require('../canvases');
const MAX_ATTEMPTS = 2;
// The one loop's agent id on the event stream and in the result (the old runtime's id, kept for readers).
@@@ -492,10 +493,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (subject) choice = { ...(choice || {}), subject };
}
try {
++ // The conversation so far, and any canvas tick the person made since the last turn (HO9): a
++ // tick starts no turn by itself, so the next run is told which item changed.
++ const context = row.parent_id && row.fork_of_step == null && !resume ? await store.followContext(s, row) : [];
++ if (!resume && !fork) context.push(...await canvases.takeNoticesForTask(s, row));
out = await withChoice(models, choice, async () => loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
// A fork carries its parent's state in the checkpoint, not as a follow-up conversation.
-- context: row.parent_id && row.fork_of_step == null && !resume ? await store.followContext(s, row) : [],
++ context,
emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }),
// The answer as it is written (plan L55 C2): to the readers that asked, never stored.
// The loop streams a model call only while such a reader is there (wanted).
ddiff --git a/test/canvas.test.js b/test/canvas.test.js
nnew file mode 100644
iindex 0000000..840f1a0
---- /dev/null
++++ b/test/canvas.test.js
@@@ -0,0 +1,192 @@
++'use strict';
++/**
++ * An agent's live panel (plan HO9, actor.canvas@1, ADR-059): the tool validates the released document and only ever
++ * grows its version; a picture from outside openvibe.media and a markup block are refused with the schema's plain
++ * reason; a button asks as the person's own message (nothing else happens) and a checklist tick flips the item and is
++ * told to the next turn; another person's canvas is 404; the conversation page carries the panel (wide and phone).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createRegistry } = require('../server/runtime/tools');
++const canvases = require('../server/canvases');
++const contracts = require('openvibe-contracts');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const JSON_ACCEPT = { ...SAME, accept: 'application/json' };
++
++const CHECKLIST = [{ type: 'heading', text: 'Lisbon, 12–14 March' },
++ { type: 'text', text: 'Three days, two people, one quiet hotel.' },
++ { type: 'checklist', id: 'packing', items: [{ id: 'bags', text: 'Pack the bags', done: false }, { id: 'passport', text: 'Passport in the bag', done: false }] },
++ { type: 'metric', label: 'Budget', value: '€480', note: 'Hotel and trains' }];
++
++(async () => {
++ // What each run was handed (the loop is the only model Actor talks to in this file). The plan and understand
++ // calls still use the stand-in providers.
++ const seen = [];
++ const loop = {
++ close: async () => {},
++ run: async (args) => {
++ seen.push(args);
++ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };
++ },
++ };
++ const t = await boot({ loop });
++ try {
++ const alice = t.network.addUser('canvas-alice');
++ const bob = t.network.addUser('canvas-bob');
++ const owner = `user:${alice.subject}`;
++ const s = t.ctx.s;
++ const agentId = s.newId('agt');
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const toolCtx = (taskId, extra = {}) => ({ store: s, requester: owner, taskId, agentId, canvasWrites: 0, ...extra });
++ const setCanvas = (taskId, input, extra) => registry.run('canvas_set', input, toolCtx(taskId, extra));
++
++ const makeTurn = async (user, text, parent = null) => {
++ const res = parent
++ ? await t.get('/chat', { as: user, form: { task: text, parent, mode: 'balanced' }, headers: SAME })
++ : await t.get('/api/v1/tasks', { as: user, json: { task: text }, headers: SAME });
++ if (parent) {
++ assert.strictEqual(res.status, 303, res.text);
++ const id = /\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})/.exec(String(res.headers.get('location') || ''))[1];
++ return await t.waitFor(id);
++ }
++ assert.strictEqual(res.status, 201, res.text);
++ const body = res.json();
++ const id = (body.task && body.task.id) || body.id;
++ return await t.waitFor(id);
++ };
++
++ const root = await makeTurn(alice, 'Plan three days in Lisbon for two');
++ let doc = null;
++ let canvasId = null;
++
++ await check('canvas_set validates the released document and only ever grows its version', async () => {
++ const first = await setCanvas(root.id, { title: 'Lisbon, 12–14 March', blocks: CHECKLIST });
++ assert.strictEqual(first.isError, false, first.text);
++ doc = first.data.canvas;
++ canvasId = doc.id;
++ assert.match(canvasId, /^cnv_/);
++ assert.strictEqual(doc.canvas, 'actor.canvas@1');
++ assert.strictEqual(doc.version, 1);
++ assert.strictEqual(doc.thread, root.id, 'the canvas sits beside the conversation, not one follow-up');
++ assert.strictEqual(doc.agent_id, agentId);
++ assert.ok(contracts.validate('actor.canvas@1', doc).valid, JSON.stringify(contracts.validate('actor.canvas@1', doc).errors));
++ assert.strictEqual(await canvases.countOf(doc), 4);
++
++ const again = await setCanvas(root.id, { title: 'Lisbon, packed', blocks: CHECKLIST.slice(0, 3) });
++ assert.strictEqual(again.isError, false, again.text);
++ assert.strictEqual(again.data.canvas.id, canvasId, 'the same canvas, not a second one');
++ assert.strictEqual(again.data.canvas.version, 2);
++ assert.strictEqual(again.data.canvas.title, 'Lisbon, packed');
++ assert.strictEqual(await s.db.value('SELECT count(*)::int FROM actor_canvases WHERE thread = $1', [root.id]), 1);
++
++ const spent = await setCanvas(root.id, { title: 'One too many', blocks: CHECKLIST }, { canvasWrites: 20 });
++ assert.strictEqual(spent.isError, true);
++ assert.match(spent.text, /at most 20 canvas_set calls/);
++ });
++
++ await check('a picture outside openvibe.media and a markup block are refused, and nothing is written', async () => {
++ const before = await s.db.maybe('SELECT version FROM actor_canvases WHERE id = $1', [canvasId]);
++ const foreign = await setCanvas(root.id, { title: 'Bad picture', blocks: [{ type: 'image', src: 'https://tracker.example/x.png', alt: 'x' }] });
++ assert.strictEqual(foreign.isError, true);
++ assert.match(foreign.text, /openvibe\.media/);
++ const markup = await setCanvas(root.id, { title: 'Markup', blocks: [{ type: 'html', text: '<b>wide</b>' }] });
++ assert.strictEqual(markup.isError, true);
++ assert.match(markup.text, /not a canvas block/);
++ const missing = await setCanvas(root.id, { title: 'Missing alt', blocks: [{ type: 'image', src: 'https://openvibe.media/photo.png' }] });
++ assert.strictEqual(missing.isError, true);
++ assert.match(missing.text, /alt/);
++ const after = await s.db.maybe('SELECT version FROM actor_canvases WHERE id = $1', [canvasId]);
++ assert.strictEqual(Number(after.version), Number(before.version), 'a refused document leaves the version alone');
++ });
++
++ await check('a canvas is the owner\'s: another person gets the same 404 as one that does not exist', async () => {
++ const res = await t.get(`/api/v1/me/canvases/${canvasId}`, { as: bob, headers: JSON_ACCEPT });
++ assert.strictEqual(res.status, 404, res.text);
++ assert.strictEqual((await t.get(`/api/v1/me/canvases?thread=${root.id}`, { as: bob })).json().canvases.length, 0);
++ assert.strictEqual((await t.get(`/api/v1/me/canvases/${canvasId}/events`, { as: bob, json: { kind: 'check', block_id: 'packing', item_id: 'bags', done: true }, headers: SAME })).status, 404);
++ assert.strictEqual((await t.get(`/chat/${root.id}`, { as: bob })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/me/canvases/${canvasId}`, { as: alice })).status, 200);
++ });
++
++ await check('ask posts the label as the person\'s own message and starts exactly one turn; nothing else happens', async () => {
++ const beforeTasks = Number(await s.db.value('SELECT count(*)::int FROM tasks'));
++ const beforeCanvas = Number(await s.db.value('SELECT version FROM actor_canvases WHERE id = $1', [canvasId]));
++ const latest = await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [owner]);
++ const res = await t.get(`/api/v1/me/canvases/${canvasId}/events`, {
++ as: alice, json: { kind: 'ask', label: 'Book the quiet hotel' }, headers: JSON_ACCEPT,
++ });
++ assert.strictEqual(res.status, 201, res.text);
++ const made = res.json();
++ assert.match(made.task.id, /^tsk_/);
++ const row = await t.waitFor(made.task.id);
++ assert.strictEqual(row.task, 'Book the quiet hotel', 'the words are the message, with no prefix');
++ assert.strictEqual(row.requester, owner);
++ assert.strictEqual(row.parent_id, latest.id, 'a normal follow-up of the conversation');
++ assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM tasks')), beforeTasks + 1, 'one turn, no other action');
++ assert.strictEqual(Number(await s.db.value('SELECT version FROM actor_canvases WHERE id = $1', [canvasId])), beforeCanvas, 'asking does not change the canvas');
++ // The text typed by the person is also a plain same-origin form: no JSON needed.
++ const form = await t.get(`/api/v1/me/canvases/${canvasId}/events`, { as: alice, form: { kind: 'ask', label: 'Send it to the hotel' }, headers: SAME });
++ assert.strictEqual(form.status, 303, form.text);
++ assert.match(String(form.headers.get('location') || ''), /\/chat\/tsk_/);
++ const typed = await t.waitFor(/\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})/.exec(String(form.headers.get('location') || ''))[1]);
++ assert.strictEqual(typed.task, 'Send it to the hotel');
++ });
++
++ await check('check flips the item, grows the version, and the next turn is told which item was ticked', async () => {
++ const before = await s.db.maybe('SELECT version FROM actor_canvases WHERE id = $1', [canvasId]);
++ const res = await t.get(`/api/v1/me/canvases/${canvasId}/events`, {
++ as: alice, json: { kind: 'check', block_id: 'packing', item_id: 'bags', done: true }, headers: JSON_ACCEPT,
++ });
++ assert.strictEqual(res.status, 200, res.text);
++ const checked = res.json().canvas;
++ assert.strictEqual(checked.version, Number(before.version) + 1);
++ const item = checked.blocks.find((b) => b.type === 'checklist').items.find((i) => i.id === 'bags');
++ assert.strictEqual(item.done, true);
++ const stored = canvases.docOf(await s.db.maybe('SELECT * FROM actor_canvases WHERE id = $1', [canvasId]));
++ assert.strictEqual(stored.version, checked.version, 'the version is the stored one');
++ const notice = (await s.db.maybe('SELECT notices FROM actor_canvases WHERE id = $1', [canvasId])).notices;
++ assert.strictEqual((typeof notice === 'string' ? JSON.parse(notice) : notice).length, 1);
++ // A tick starts no turn by itself; the next one is told as context.
++ const latest = await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [owner]);
++ const next = await makeTurn(alice, 'Now pack for the trip', latest.id);
++ const handed = seen[seen.length - 1];
++ assert.ok(Array.isArray(handed.context), 'the run gets a context');
++ assert.ok(handed.context.some((c) => /Pack the bags/.test(c.answer) && /Canvas "Lisbon, packed"/.test(c.task)), JSON.stringify(handed.context));
++ assert.strictEqual(next.task, 'Now pack for the trip');
++ const left = (await s.db.maybe('SELECT notices FROM actor_canvases WHERE id = $1', [canvasId])).notices;
++ assert.deepStrictEqual(typeof left === 'string' ? JSON.parse(left) : left, [], 'told once, then cleared');
++ });
++
++ await check('the conversation page carries the canvas: a third column wide, a closed fold above the composer', async () => {
++ const page = await t.get(`/chat/${root.id}`, { as: alice });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.match(page.text, /data-cc-canvas/);
++ assert.match(page.text, /cc-canvas-wide/);
++ assert.match(page.text, /cc-canvas-phone/);
++ assert.match(page.text, /<details class="cc-canvas cc-canvas-phone" data-cc-canvas/);
++ assert.ok(!/cc-canvas-phone" open/.test(page.text), 'the phone fold starts closed');
++ assert.match(page.text, /data-cc-canvas-updated[^>]*hidden/);
++ assert.match(page.text, /Canvas[\s\S]{0,120}items/);
++ assert.match(page.text, /shared\/ov-canvas\.js/);
++ assert.match(page.text, /Lisbon, packed/);
++ assert.match(page.text, /Passport in the bag/);
++ // The wide copy is a real panel beside the conversation (thread.css's third column); the phone fold's
++ // body scrolls inside at most 45vh and its containment keeps that scroll out of the page's height.
++ assert.match(page.text, /class="ovt-panel cc-canvas cc-canvas-wide"/);
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
++ assert.match(css, /min\(45vh, 420px\)/);
++ assert.match(css, /contain: layout paint/);
++ });
++
++ await check('account data names the canvas table (export and deletion cover it)', async () => {
++ const { TABLES } = require('../server/identity/account-data');
++ const entry = TABLES.find((x) => x.table === 'actor_canvases');
++ assert.ok(entry, 'actor_canvases is in the account-data map');
++ assert.strictEqual(entry.subject, 'owner');
++ assert.strictEqual(entry.value('usr_TEST'), 'user:usr_TEST');
++ assert.ok(entry.columns.includes('document') && entry.columns.includes('version'));
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/tool-acts.test.js b/test/tool-acts.test.js
iindex 2da49c8..dd2a279 100644
---- a/test/tool-acts.test.js
++++ b/test/tool-acts.test.js
@@@ -11,6 +11,7 @@ const { check, done } = require('./helpers/boot');
const EXPECTED = {
// Read-only: they only read what is already there, so a resume may run them again.
calculate: false,
++ canvas_set: false,
convert_time: false,
currency_convert: false,
computer_list: false,