openvibers/openvibe.link Commits
v0.21.0: element refs and receipts in the agent's browser (Desks plan D3) (#67)
Changes
@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.20.0",-- "release": "v0.20.0, published 2026-10-27 as a GitHub release (desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",++ "version": "0.21.0",++ "release": "v0.21.0, published 2026-10-27 as a GitHub release (element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -121,6 +121,15 @@ text and keys are passed to the tool as arguments, never through a shell, and `k- **No port.** The Node starts it on the first command with `--remote-debugging-pipe`: it speaks the DevTools protocol over two file descriptors, so nothing else on the machine can drive it.- **Levels.** `read`, `screenshot` and `close` run at every level. `open`, `click`, `fill`, `press` and `back` are acting commands: refused at `observe`, approved one by one at `ask`. The approval says exactly what would happen: "open https://… in the browser", "type 5 characters into #q in the browser: 'hello'".- **Your own network.** Below level `full`, `open` refuses an address on the machine's own network (private, loopback or link-local, checked for every address the name resolves to).++- **Elements and receipts** (Node v0.21.0, the Desks plan's step D3). `open` and `read` list the page's interactive++ elements, one compact line each: `e7 button "Sign in"`, `e8 textbox "Email" (email)`, `e9 checkbox "Remember me"++ checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or++ placeholder. Each element keeps its ref (`data-ov-ref`) while it stays on the page, so `click` or `fill` with the++ selector `[data-ov-ref="e7"]` acts on exactly that element. An agent works from the page's own structure first and++ needs a screenshot only when that is not enough.++- **Receipts.** `click`, `press` and `back` answer with a `receipt` of what they changed: `url_changed`, the first lines++ that `appeared` (up to 8) and how many lines are `gone`. An agent knows at once whether its click did anything,++ without another read.- **Closing.** It closes after `browser_idle_s` without a command and at the kill switch.- **Platforms.** Linux and macOS; not offered on Windows yet.@@@ -143,7 +143,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.9.0"++ version = "0.10.0"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -22,6 +22,8 @@ import timeMAX_TEXT = 20000STARTUP_S = 45.0MAX_LINKS = 60++MAX_ELEMENTS = 120++MAX_APPEARED = 8KEY_CODES = {"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),@@@ -33,14 +35,57 @@ MAC_APPS = ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge","/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")++# The page as text, its links, and its interactive elements as refs (Desks plan D3: act on refs from the page's own++# structure before ever reaching for a screenshot). An element keeps its ref (data-ov-ref) for as long as it stays on++# the page, so a ref from one read still names the same element after the page changed around it. Each element is one++# compact line, `e7 button "Sign in"`, which costs a model a fraction of a screenshot.SUMMARY_JS = """(() => {const text = (document.body ? document.body.innerText : '').replace(/\\n{3,}/g, '\\n\\n');const links = [...document.querySelectorAll('a[href]')].filter((a) => /^https?:/.test(a.href)).slice(0, %d).map((a) => ({ text: (a.innerText || a.getAttribute('aria-label') || '').trim().slice(0, 100), href: a.href }));-- return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links };--})()""" % (MAX_LINKS, MAX_TEXT, MAX_TEXT)++ const SEL = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [contenteditable=""], [contenteditable=true],'++ + ' [role=button], [role=link], [role=checkbox], [role=radio], [role=tab], [role=menuitem], [role=switch], [role=option], [role=combobox]';++ const shown = (el) => { const r = el.getClientRects(); if (!r.length) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; };++ const roleOf = (el) => el.getAttribute('role') || ({ A: 'link', BUTTON: 'button', SELECT: 'select', TEXTAREA: 'textbox', SUMMARY: 'button' })[el.tagName]++ || (el.tagName === 'INPUT' ? ({ checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button', reset: 'button', range: 'slider', file: 'file' })[el.type] || 'textbox' : 'textbox');++ const labelOf = (el) => (el.labels && el.labels.length ? el.labels[0].innerText : '');++ const valueName = (el) => (['checkbox', 'radio'].includes(el.type) ? '' : el.value);++ const nameOf = (el) => (el.getAttribute('aria-label') || labelOf(el) || el.innerText || valueName(el) || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 80);++ window.__ovRef = window.__ovRef || 0;++ const elements = [];++ for (const el of document.querySelectorAll(SEL)) {++ if (elements.length >= %d) break;++ if (!shown(el)) continue;++ if (!el.dataset.ovRef) el.dataset.ovRef = 'e' + (++window.__ovRef);++ const role = roleOf(el);++ let line = el.dataset.ovRef + ' ' + role + ' ' + JSON.stringify(nameOf(el));++ if (role === 'textbox' && el.tagName === 'INPUT' && el.type && el.type !== 'text') line += ' (' + el.type + ')';++ if (el.checked) line += ' checked';++ if (el.disabled) line += ' disabled';++ if (role === 'textbox' && el.value) line += ' value=' + JSON.stringify(String(el.value).slice(0, 40));++ elements.push(line);++ }++ return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links, elements };++})()""" % (MAX_LINKS, MAX_ELEMENTS, MAX_TEXT, MAX_TEXT)++++# What the page says before an action, to tell what the action changed (its receipt).++BEFORE_JS = """(() => ({ url: location.href, text: (document.body ? document.body.innerText : '').slice(0, 60000) }))()"""++++++def receipt(before, after):++ """What an action changed (Desks plan D3), from the page before and after it: whether it went to another address,++ the first lines that appeared (MAX_APPEARED), and how many lines went away. An agent reads at once whether its click++ did anything, without another read or a screenshot."""++ if not isinstance(before, dict):++ return None++ old = [l.strip() for l in str(before.get("text") or "").splitlines() if l.strip()]++ new = [l.strip() for l in str(after.get("text") or "").splitlines() if l.strip()]++ old_set, new_set = set(old), set(new)++ appeared = [l[:160] for l in new if l not in old_set][:MAX_APPEARED]++ gone = len([l for l in old_set if l not in new_set])++ return {"url_changed": before.get("url") != after.get("url"), "appeared": appeared, "gone": gone}CLICK_JS = """((selector, text) => {let el = null;@@@ -258,13 +303,27 @@ class Browser:self._need()return self.summary()++ def _before(self):++ try:++ return self._eval(BEFORE_JS)++ except BrowserError:++ return None++++ def _with_receipt(self, before):++ after = self.summary()++ r = receipt(before, after)++ if r is not None:++ after["receipt"] = r++ return after++def click(self, selector=None, text=None):self._need()++ before = self._before()self._mark()if not self._eval(CLICK_JS % (json.dumps(selector), json.dumps(text or ""))):raise BrowserError("bad_value", "nothing on the page matches %s" % (selector or repr(text)))self._settle()-- return self.summary()++ return self._with_receipt(before)def fill(self, selector, text):self._need()@@@ -276,19 +335,21 @@ class Browser:def press(self, key):self._need()code, text = KEY_CODES[key]++ before = self._before()self._mark()base = {"key": key, "code": key if key != "Space" else "Space", "windowsVirtualKeyCode": code, "nativeVirtualKeyCode": code}self.call("Input.dispatchKeyEvent", dict(base, type="keyDown", **({"text": text} if text else {})))self.call("Input.dispatchKeyEvent", dict(base, type="keyUp"))self._settle()-- return self.summary()++ return self._with_receipt(before)def back(self):self._need()++ before = self._before()self._mark()self._eval("history.back()")self._settle()-- return self.summary()++ return self._with_receipt(before)def screenshot(self, max_width=1280):self._need()@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-computer"--version = "0.9.0"++version = "0.10.0"description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -160,11 +160,28 @@ class BrowserKindTest(unittest.TestCase):self.assertEqual(reply(self.out, self.cmd({"action": "close"}))["data"], {"done": True})++class ReceiptTest(unittest.TestCase):++ def test_what_an_action_changed(self):++ from openvibe_computer.browser import receipt++ before = {"url": "https://a/", "text": "One\nTwo\n\nThree"}++ self.assertEqual(receipt(before, {"url": "https://a/", "text": "One\nTwo\nThree"}), {"url_changed": False, "appeared": [], "gone": 0})++ self.assertEqual(receipt(before, {"url": "https://a/#x", "text": "One\nFour\nThree\n" + "\n".join("n%d" % i for i in range(20))}),++ {"url_changed": True, "appeared": ["Four"] + ["n%d" % i for i in range(7)], "gone": 1})++ self.assertIsNone(receipt(None, {"url": "x", "text": ""}), "no receipt without a before")++ long = receipt(before, {"url": "https://a/", "text": "x" * 500})++ self.assertEqual(len(long["appeared"][0]), 160)++++class Handler(http.server.SimpleHTTPRequestHandler):PAGES = {"/": b"<!doctype html><title>Home</title><h1>Hello</h1><a href='/two'>Second page</a>"b"<form action='/two'><input id='q' name='q'><button>Search</button></form>","/two": b"<!doctype html><title>Two</title><p>Arrived</p>",++ # Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements.++ "/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"++ b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"++ b"<button style='display:none'>Hidden</button><div id='rows'></div>"++ b"<script>document.getElementById('go').onclick = () => { document.getElementById('rows').insertAdjacentHTML('beforeend', '<p>Row added</p>'); document.querySelector('p').remove(); };</script>",}def do_GET(self):@@@ -200,6 +217,20 @@ class RealBrowserTest(unittest.TestCase):self.assertTrue(b.press("Enter")["url"].endswith("/two?q=two+words"))shot = b.screenshot(640)self.assertEqual((shot["format"], shot["width"] <= 1280), ("jpeg", True))++ # Desks plan D3: the page's interactive elements as refs, and a receipt for each action.++ app = b.open(base + "/app")++ refs = {line.split(" ", 2)[2].split('"')[1]: line.split(" ", 1)[0] for line in app["elements"]}++ self.assertEqual(set(refs), {"Add a row", "Email", "", "Second page"}, app["elements"])++ self.assertTrue(any(l.endswith(' "Email" (email)') for l in app["elements"]), "an input says its type")++ self.assertTrue(any(" checkbox " in l and l.endswith(" checked") for l in app["elements"]), "a checkbox says it is checked")++ self.assertFalse(any("Hidden" in l for l in app["elements"]), "a hidden element is not offered")++ done = b.click(selector='[data-ov-ref="%s"]' % refs["Add a row"])++ self.assertEqual(done["receipt"], {"url_changed": False, "appeared": ["Row added"], "gone": 1})++ again = b.read()++ self.assertIn('%s button "Add a row"' % refs["Add a row"], again["elements"], "the same element keeps its ref")++ away = b.click(selector='[data-ov-ref="%s"]' % refs["Second page"])++ self.assertTrue(away["receipt"]["url_changed"])++ self.assertEqual(b.back()["receipt"]["url_changed"], True)self.assertTrue(shot["content_b64"])b.close()self.assertFalse(b.alive())@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]# The Node release this runtime ships with (pyproject.toml carries the same).--__version__ = "0.20.0"++__version__ = "0.21.0"PROTOCOL_VERSION = 1HEARTBEAT_TIMEOUT_S = 1.0DEFAULT_DEADLINE_MS = 300@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-plugin"--version = "0.20.0"++version = "0.21.0"description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)def test_the_package_reports_the_node_release(self):-- self.assertEqual(openvibe_plugin.__version__, "0.20.0")++ self.assertEqual(openvibe_plugin.__version__, "0.21.0")with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.20.0",
- "release": "v0.20.0, published 2026-10-27 as a GitHub release (desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
+ "version": "0.21.0",
+ "release": "v0.21.0, published 2026-10-27 as a GitHub release (element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -121,6 +121,15 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
@@ -121,6 +121,15 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
- **No port.** The Node starts it on the first command with `--remote-debugging-pipe`: it speaks the DevTools protocol over two file descriptors, so nothing else on the machine can drive it.
- **No port.** The Node starts it on the first command with `--remote-debugging-pipe`: it speaks the DevTools protocol over two file descriptors, so nothing else on the machine can drive it.
- **Levels.** `read`, `screenshot` and `close` run at every level. `open`, `click`, `fill`, `press` and `back` are acting commands: refused at `observe`, approved one by one at `ask`. The approval says exactly what would happen: "open https://… in the browser", "type 5 characters into #q in the browser: 'hello'".
- **Levels.** `read`, `screenshot` and `close` run at every level. `open`, `click`, `fill`, `press` and `back` are acting commands: refused at `observe`, approved one by one at `ask`. The approval says exactly what would happen: "open https://… in the browser", "type 5 characters into #q in the browser: 'hello'".
- **Your own network.** Below level `full`, `open` refuses an address on the machine's own network (private, loopback or link-local, checked for every address the name resolves to).
- **Your own network.** Below level `full`, `open` refuses an address on the machine's own network (private, loopback or link-local, checked for every address the name resolves to).
+- **Elements and receipts** (Node v0.21.0, the Desks plan's step D3). `open` and `read` list the page's interactive
+ elements, one compact line each: `e7 button "Sign in"`, `e8 textbox "Email" (email)`, `e9 checkbox "Remember me"
+ checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or
+ placeholder. Each element keeps its ref (`data-ov-ref`) while it stays on the page, so `click` or `fill` with the
+ selector `[data-ov-ref="e7"]` acts on exactly that element. An agent works from the page's own structure first and
+ needs a screenshot only when that is not enough.
+- **Receipts.** `click`, `press` and `back` answer with a `receipt` of what they changed: `url_changed`, the first lines
+ that `appeared` (up to 8) and how many lines are `gone`. An agent knows at once whether its click did anything,
+ without another read.
- **Closing.** It closes after `browser_idle_s` without a command and at the kill switch.
- **Closing.** It closes after `browser_idle_s` without a command and at the kill switch.
- **Platforms.** Linux and macOS; not offered on Windows yet.
- **Platforms.** Linux and macOS; not offered on Windows yet.
@@ -143,7 +143,7 @@ def _int(v, name, lo, hi, default):
@@ -143,7 +143,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.9.0"
+ version = "0.10.0"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -22,6 +22,8 @@ import time
@@ -22,6 +22,8 @@ import time
MAX_TEXT = 20000
MAX_TEXT = 20000
STARTUP_S = 45.0
STARTUP_S = 45.0
MAX_LINKS = 60
MAX_LINKS = 60
+MAX_ELEMENTS = 120
+MAX_APPEARED = 8
KEY_CODES = {
KEY_CODES = {
"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),
"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),
"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),
"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),
@@ -33,14 +35,57 @@ MAC_APPS = ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
@@ -33,14 +35,57 @@ MAC_APPS = ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
+# The page as text, its links, and its interactive elements as refs (Desks plan D3: act on refs from the page's own
+# structure before ever reaching for a screenshot). An element keeps its ref (data-ov-ref) for as long as it stays on
+# the page, so a ref from one read still names the same element after the page changed around it. Each element is one
+# compact line, `e7 button "Sign in"`, which costs a model a fraction of a screenshot.
SUMMARY_JS = """(() => {
SUMMARY_JS = """(() => {
const text = (document.body ? document.body.innerText : '').replace(/\\n{3,}/g, '\\n\\n');
const text = (document.body ? document.body.innerText : '').replace(/\\n{3,}/g, '\\n\\n');
const links = [...document.querySelectorAll('a[href]')]
const links = [...document.querySelectorAll('a[href]')]
.filter((a) => /^https?:/.test(a.href))
.filter((a) => /^https?:/.test(a.href))
.slice(0, %d)
.slice(0, %d)
.map((a) => ({ text: (a.innerText || a.getAttribute('aria-label') || '').trim().slice(0, 100), href: a.href }));
.map((a) => ({ text: (a.innerText || a.getAttribute('aria-label') || '').trim().slice(0, 100), href: a.href }));
- return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links };
-})()""" % (MAX_LINKS, MAX_TEXT, MAX_TEXT)
+ const SEL = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [contenteditable=""], [contenteditable=true],'
+ + ' [role=button], [role=link], [role=checkbox], [role=radio], [role=tab], [role=menuitem], [role=switch], [role=option], [role=combobox]';
+ const shown = (el) => { const r = el.getClientRects(); if (!r.length) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; };
+ const roleOf = (el) => el.getAttribute('role') || ({ A: 'link', BUTTON: 'button', SELECT: 'select', TEXTAREA: 'textbox', SUMMARY: 'button' })[el.tagName]
+ || (el.tagName === 'INPUT' ? ({ checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button', reset: 'button', range: 'slider', file: 'file' })[el.type] || 'textbox' : 'textbox');
+ const labelOf = (el) => (el.labels && el.labels.length ? el.labels[0].innerText : '');
+ const valueName = (el) => (['checkbox', 'radio'].includes(el.type) ? '' : el.value);
+ const nameOf = (el) => (el.getAttribute('aria-label') || labelOf(el) || el.innerText || valueName(el) || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 80);
+ window.__ovRef = window.__ovRef || 0;
+ const elements = [];
+ for (const el of document.querySelectorAll(SEL)) {
+ if (elements.length >= %d) break;
+ if (!shown(el)) continue;
+ if (!el.dataset.ovRef) el.dataset.ovRef = 'e' + (++window.__ovRef);
+ const role = roleOf(el);
+ let line = el.dataset.ovRef + ' ' + role + ' ' + JSON.stringify(nameOf(el));
+ if (role === 'textbox' && el.tagName === 'INPUT' && el.type && el.type !== 'text') line += ' (' + el.type + ')';
+ if (el.checked) line += ' checked';
+ if (el.disabled) line += ' disabled';
+ if (role === 'textbox' && el.value) line += ' value=' + JSON.stringify(String(el.value).slice(0, 40));
+ elements.push(line);
+ }
+ return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links, elements };
+})()""" % (MAX_LINKS, MAX_ELEMENTS, MAX_TEXT, MAX_TEXT)
+
+# What the page says before an action, to tell what the action changed (its receipt).
+BEFORE_JS = """(() => ({ url: location.href, text: (document.body ? document.body.innerText : '').slice(0, 60000) }))()"""
+
+
+def receipt(before, after):
+ """What an action changed (Desks plan D3), from the page before and after it: whether it went to another address,
+ the first lines that appeared (MAX_APPEARED), and how many lines went away. An agent reads at once whether its click
+ did anything, without another read or a screenshot."""
+ if not isinstance(before, dict):
+ return None
+ old = [l.strip() for l in str(before.get("text") or "").splitlines() if l.strip()]
+ new = [l.strip() for l in str(after.get("text") or "").splitlines() if l.strip()]
+ old_set, new_set = set(old), set(new)
+ appeared = [l[:160] for l in new if l not in old_set][:MAX_APPEARED]
+ gone = len([l for l in old_set if l not in new_set])
+ return {"url_changed": before.get("url") != after.get("url"), "appeared": appeared, "gone": gone}
CLICK_JS = """((selector, text) => {
CLICK_JS = """((selector, text) => {
let el = null;
let el = null;
@@ -258,13 +303,27 @@ class Browser:
@@ -258,13 +303,27 @@ class Browser:
self._need()
self._need()
return self.summary()
return self.summary()
+ def _before(self):
+ try:
+ return self._eval(BEFORE_JS)
+ except BrowserError:
+ return None
+
+ def _with_receipt(self, before):
+ after = self.summary()
+ r = receipt(before, after)
+ if r is not None:
+ after["receipt"] = r
+ return after
+
def click(self, selector=None, text=None):
def click(self, selector=None, text=None):
self._need()
self._need()
+ before = self._before()
self._mark()
self._mark()
if not self._eval(CLICK_JS % (json.dumps(selector), json.dumps(text or ""))):
if not self._eval(CLICK_JS % (json.dumps(selector), json.dumps(text or ""))):
raise BrowserError("bad_value", "nothing on the page matches %s" % (selector or repr(text)))
raise BrowserError("bad_value", "nothing on the page matches %s" % (selector or repr(text)))
self._settle()
self._settle()
- return self.summary()
+ return self._with_receipt(before)
def fill(self, selector, text):
def fill(self, selector, text):
self._need()
self._need()
@@ -276,19 +335,21 @@ class Browser:
@@ -276,19 +335,21 @@ class Browser:
def press(self, key):
def press(self, key):
self._need()
self._need()
code, text = KEY_CODES[key]
code, text = KEY_CODES[key]
+ before = self._before()
self._mark()
self._mark()
base = {"key": key, "code": key if key != "Space" else "Space", "windowsVirtualKeyCode": code, "nativeVirtualKeyCode": code}
base = {"key": key, "code": key if key != "Space" else "Space", "windowsVirtualKeyCode": code, "nativeVirtualKeyCode": code}
self.call("Input.dispatchKeyEvent", dict(base, type="keyDown", **({"text": text} if text else {})))
self.call("Input.dispatchKeyEvent", dict(base, type="keyDown", **({"text": text} if text else {})))
self.call("Input.dispatchKeyEvent", dict(base, type="keyUp"))
self.call("Input.dispatchKeyEvent", dict(base, type="keyUp"))
self._settle()
self._settle()
- return self.summary()
+ return self._with_receipt(before)
def back(self):
def back(self):
self._need()
self._need()
+ before = self._before()
self._mark()
self._mark()
self._eval("history.back()")
self._eval("history.back()")
self._settle()
self._settle()
- return self.summary()
+ return self._with_receipt(before)
def screenshot(self, max_width=1280):
def screenshot(self, max_width=1280):
self._need()
self._need()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-computer"
name = "openvibe-computer"
-version = "0.9.0"
+version = "0.10.0"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -160,11 +160,28 @@ class BrowserKindTest(unittest.TestCase):
@@ -160,11 +160,28 @@ class BrowserKindTest(unittest.TestCase):
self.assertEqual(reply(self.out, self.cmd({"action": "close"}))["data"], {"done": True})
self.assertEqual(reply(self.out, self.cmd({"action": "close"}))["data"], {"done": True})
+class ReceiptTest(unittest.TestCase):
+ def test_what_an_action_changed(self):
+ from openvibe_computer.browser import receipt
+ before = {"url": "https://a/", "text": "One\nTwo\n\nThree"}
+ self.assertEqual(receipt(before, {"url": "https://a/", "text": "One\nTwo\nThree"}), {"url_changed": False, "appeared": [], "gone": 0})
+ self.assertEqual(receipt(before, {"url": "https://a/#x", "text": "One\nFour\nThree\n" + "\n".join("n%d" % i for i in range(20))}),
+ {"url_changed": True, "appeared": ["Four"] + ["n%d" % i for i in range(7)], "gone": 1})
+ self.assertIsNone(receipt(None, {"url": "x", "text": ""}), "no receipt without a before")
+ long = receipt(before, {"url": "https://a/", "text": "x" * 500})
+ self.assertEqual(len(long["appeared"][0]), 160)
+
+
class Handler(http.server.SimpleHTTPRequestHandler):
class Handler(http.server.SimpleHTTPRequestHandler):
PAGES = {
PAGES = {
"/": b"<!doctype html><title>Home</title><h1>Hello</h1><a href='/two'>Second page</a>"
"/": b"<!doctype html><title>Home</title><h1>Hello</h1><a href='/two'>Second page</a>"
b"<form action='/two'><input id='q' name='q'><button>Search</button></form>",
b"<form action='/two'><input id='q' name='q'><button>Search</button></form>",
"/two": b"<!doctype html><title>Two</title><p>Arrived</p>",
"/two": b"<!doctype html><title>Two</title><p>Arrived</p>",
+ # Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements.
+ "/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"
+ b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"
+ b"<button style='display:none'>Hidden</button><div id='rows'></div>"
+ b"<script>document.getElementById('go').onclick = () => { document.getElementById('rows').insertAdjacentHTML('beforeend', '<p>Row added</p>'); document.querySelector('p').remove(); };</script>",
}
}
def do_GET(self):
def do_GET(self):
@@ -200,6 +217,20 @@ class RealBrowserTest(unittest.TestCase):
@@ -200,6 +217,20 @@ class RealBrowserTest(unittest.TestCase):
self.assertTrue(b.press("Enter")["url"].endswith("/two?q=two+words"))
self.assertTrue(b.press("Enter")["url"].endswith("/two?q=two+words"))
shot = b.screenshot(640)
shot = b.screenshot(640)
self.assertEqual((shot["format"], shot["width"] <= 1280), ("jpeg", True))
self.assertEqual((shot["format"], shot["width"] <= 1280), ("jpeg", True))
+ # Desks plan D3: the page's interactive elements as refs, and a receipt for each action.
+ app = b.open(base + "/app")
+ refs = {line.split(" ", 2)[2].split('"')[1]: line.split(" ", 1)[0] for line in app["elements"]}
+ self.assertEqual(set(refs), {"Add a row", "Email", "", "Second page"}, app["elements"])
+ self.assertTrue(any(l.endswith(' "Email" (email)') for l in app["elements"]), "an input says its type")
+ self.assertTrue(any(" checkbox " in l and l.endswith(" checked") for l in app["elements"]), "a checkbox says it is checked")
+ self.assertFalse(any("Hidden" in l for l in app["elements"]), "a hidden element is not offered")
+ done = b.click(selector='[data-ov-ref="%s"]' % refs["Add a row"])
+ self.assertEqual(done["receipt"], {"url_changed": False, "appeared": ["Row added"], "gone": 1})
+ again = b.read()
+ self.assertIn('%s button "Add a row"' % refs["Add a row"], again["elements"], "the same element keeps its ref")
+ away = b.click(selector='[data-ov-ref="%s"]' % refs["Second page"])
+ self.assertTrue(away["receipt"]["url_changed"])
+ self.assertEqual(b.back()["receipt"]["url_changed"], True)
self.assertTrue(shot["content_b64"])
self.assertTrue(shot["content_b64"])
b.close()
b.close()
self.assertFalse(b.alive())
self.assertFalse(b.alive())
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
# The Node release this runtime ships with (pyproject.toml carries the same).
-__version__ = "0.20.0"
+__version__ = "0.21.0"
PROTOCOL_VERSION = 1
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
DEFAULT_DEADLINE_MS = 300
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-plugin"
name = "openvibe-plugin"
-version = "0.20.0"
+version = "0.21.0"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
def test_the_package_reports_the_node_release(self):
- self.assertEqual(openvibe_plugin.__version__, "0.20.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.21.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())