openvibers/openvibe.link Commits
v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (one chat completion) to a local model server (Ollama, LM Studio, llama.cpp) with no open port; the core routes ai to it (#74)
Changes
@@@ -61,6 +61,10 @@ your answer in the tray, a notification's Approve button or `openvibe-node approWith it on, you can also watch the machine's screen live on openvibe.bot. Only you see it, it runs for up to 30minutes, and `openvibe-node stop` ends it at once ([the live screen](docs/computer.md#the-live-screen-screen_stream-node-v0120)).++**Your own models for your OpenVibe work:** the `ai` plugin lets OpenVibe use a model you already run at home (Ollama,++LM Studio or llama.cpp's server) for your own work, at no cost and with no open port, once you turn on "Let my AI use++this computer's models" on openvibe.bot/machines ([docs/ai.md](docs/ai.md)).++**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: thejob worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a codingtask on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).@@@ -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.26.0",-- "release": "v0.26.0, published 2026-10-27 as a GitHub release (programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: 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",++ "version": "0.27.0",++ "release": "v0.27.0, published 2026-10-27 as a GitHub release (your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: 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,nnew file mode 100644@@@ -0,0 +1,54 @@++# Your own models for your OpenVibe work++++If you already run a model at home (Ollama, LM Studio or llama.cpp's server on a gaming PC or a spare box with a GPU),++OpenVibe can use it for your own work: Vibe's answers, an agent's thinking, a summary. It costs nothing, and the prompt++never leaves your network. The machine opens no port: its OpenVibe Node already connects out to OpenVibe.Bot, and the++request comes back over that connection.++++## How it works++++1. OpenVibe.AI, on your behalf, asks OpenVibe.Bot to run one chat completion on your machine (`bot.command@1` kind++ `ai`, contracts 0.219.0).++2. Bot sends it to your Node, but only for your own machine, and only when you turned on **Let my AI use this++ computer's models** on openvibe.bot/machines. One person's request never goes to another person's machine.++3. The Node's `ai` plugin relays it to your model server, and the reply goes back the same way.++++The plugin reaches one place only: the model server in its config. It reads nothing else on the machine and changes++nothing there. It runs one request at a time, because a model is heavy; another request is refused as busy, not++queued. The audit on Bot keeps the model and the token counts, never the text.++++## Set it up++++Add the plugin to the Node's config (`config.json`):++++```json++{ "plugins": [{ "name": "ai" }] }++```++++With nothing else set, it uses the first of these that answers:++++| Server | Address |++|---|---|++| Ollama | `http://127.0.0.1:11434` |++| LM Studio | `http://127.0.0.1:1234` |++| llama.cpp's server | `http://127.0.0.1:8080` |++++It offers every model the server lists. Restart the Node (`openvibe-node restart`), then turn on **Let my AI use this++computer's models** for the machine on openvibe.bot/machines.++++| key | default | meaning |++|---|---|---|++| `endpoint` | the first that answers above | the model server's OpenAI-compatible base, without `/v1` |++| `models` | everything the server lists | the models you offer (a list) |++| `max_tokens` | `2048` | the most one reply may be (at most 32768) |++| `timeout_s` | `180` | how long one reply may take (at most 900) |++++## The command++++`ai` `{"model", "messages": [{"role": "system"|"user"|"assistant", "content"}], "max_tokens"?, "temperature"?}` →++`{model, message: {role: "assistant", content}, usage?, finish_reason?, ms}`.++++- At most 200 messages of 64 KB each. A model the machine does not offer is refused (`bad_value`), and so is a request++ while another runs (`not_ready`).++- When no model server answers, the command is `not_ready` and `describe` says `ai.ready: false`.++- It never acts on the machine, so it also runs while the machine's stop is latched.@@@ -344,6 +344,7 @@ var Bundled = map[string]string{"ros2": "openvibe_ros2","computer": "openvibe_computer","voice": "openvibe_voice",++ "ai": "openvibe_ai",}// Default is the config written by `openvibe-node pair` when none exists: the dry-run plugin and its test pattern.@@@ -25,6 +25,7 @@ var capabilityFor = map[string]string{protocol.KindInput: "input",protocol.KindBrowser: "browser",protocol.KindDesk: "desk",++ protocol.KindAI: "ai",}// deskKinds are the kinds a plugin's desks take too. A desk host (install.sh --desk-host) has no screen, mouse or@@@ -70,6 +70,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {if mode == "deskhost" { // a desk host's computer plugin: desks and files, no screen or input of its owncaps = map[string]any{"desk": map[string]any{"screen": true, "input": true}, "files": map[string]any{}}}++ if mode == "aihost" { // the ai plugin: the person's own models++ caps = map[string]any{"ai": map[string]any{"models": []string{"llama3.1:8b"}, "ready": true}}++ }w.Encode(map[string]any{"op": "describe", "driver": "fake", "version": "1", "capabilities": caps})if m["probe"] == true {continue@@@ -285,6 +288,20 @@ func TestRoutingOnADeskHost(t *testing.T) {}}++// Kind ai (contracts 0.219.0) reaches the plugin that offers "ai", and only it.++func TestRoutingAIToTheModelPlugin(t *testing.T) {++ fx := start(t, "aihost")++ fx.waitState(StateReady)++ if fx.m.Route("ai", "", "") == nil {++ t.Fatal("ai not routed to the plugin that offers it")++ }++ for _, kind := range []string{"exec", "screen", "drive"} {++ if fx.m.Route(kind, "", "") != nil {++ t.Fatalf("%s routed to the ai plugin", kind)++ }++ }++}++func TestShutdownStopsAndClosesStdin(t *testing.T) {fx := start(t, "")fx.waitState(StateReady)@@@ -188,13 +188,17 @@ const (// "status", "width"?, "height"?}, an Xvfb display of its own where an agent works without touching the owner's// screen; screen, screen_stream, input and browser take "desk": "private" to act there. Starting and stopping act.KindDesk = "desk"++ // KindAI is one chat completion on the person's own model server (contracts 0.219.0, the ai plugin): {"model",++ // "messages", "max_tokens"?, "temperature"?} → {model, message, usage?}. It reads and changes nothing on the++ // machine, so it never acts and runs while a stop is latched like a read.++ KindAI = "ai")// Kinds lists every command kind the Node understands.--var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk}++var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk, KindAI}// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.--var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true, KindDesk: true}++var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true, KindDesk: true, KindAI: true}const (// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS capsnnew file mode 100644@@@ -0,0 +1,185 @@++"""OpenVibe Node ai plugin: the person's own models for their own OpenVibe work (contracts 0.219.0, kind ``ai``).++++Many people already run models at home (Ollama, llama.cpp's server, LM Studio). This plugin lets OpenVibe use them for++their owner, for Vibe's answers or an agent's thinking, without the prompt leaving the machine's network and with no++open port: OpenVibe.AI asks OpenVibe.Bot, Bot sends the Node one ``ai`` command over the connection the Node already++keeps, and this plugin relays it to the local model server and answers with the reply.++++It reaches one place only, the server in its config, and it reads and changes nothing else on the machine. One request++runs at a time (a model is heavy); another is refused as busy, never queued. The owner turns it on twice: by adding the++plugin here, and by "Let my AI use this computer's models" on openvibe.bot/machines.++++Config (the plugin's block in config.json):++++* ``endpoint``: the model server's OpenAI-compatible base, without ``/v1``. By default the first one that answers of++ Ollama (``http://127.0.0.1:11434``), LM Studio (``http://127.0.0.1:1234``) and llama.cpp's server++ (``http://127.0.0.1:8080``).++* ``models``: the models offered (a list); by default every model the server lists.++* ``max_tokens``: the most a reply may be (default 2048, at most 32768).++* ``timeout_s``: how long one reply may take (default 180, at most 900).++++Command ``ai`` ``{"model", "messages": [{"role", "content"}], "max_tokens"?, "temperature"?}`` →++``{model, message: {role: "assistant", content}, usage?, finish_reason?}``.++"""++++import json++import threading++import time++import urllib.error++import urllib.request++++from openvibe_plugin import Deferred, Fault, Plugin++++VERSION = "0.1.0"++DEFAULT_ENDPOINTS = ("http://127.0.0.1:11434", "http://127.0.0.1:1234", "http://127.0.0.1:8080")++MAX_REPLY_CHARS = 200 * 1024 # well inside the core's answer limit (512 KB)++MAX_MESSAGES = 200++MAX_CONTENT = 64 * 1024++ROLES = ("system", "user", "assistant")++++++def _int(v, name, lo, hi, default):++ if v is None:++ return default++ if isinstance(v, bool) or not isinstance(v, (int, float)):++ raise Fault("bad_value", "%s must be a number" % name)++ return int(max(lo, min(hi, v)))++++++class Ai(Plugin):++ driver = "ai"++ version = VERSION++ motion_kinds = frozenset() # it moves nothing++++ def __init__(self, opener=None, clock=time.monotonic):++ self.opener = opener or urllib.request.urlopen++ self.clock = clock++ self.endpoint = None++ self.models = []++ self.max_tokens = 2048++ self.timeout_s = 180++ self.busy = threading.Lock()++ self.cfg = {}++ self.reachable = False++++ # ---- the model server -----------------------------------------------------------------------------------------++ def _get(self, url, timeout):++ req = urllib.request.Request(url, headers={"Accept": "application/json"})++ with self.opener(req, timeout=timeout) as r:++ return json.loads(r.read(1024 * 1024).decode("utf-8"))++++ def _list_models(self, endpoint, timeout=3.0):++ """The models a server lists (OpenAI-compatible /v1/models), or None when it does not answer."""++ try:++ doc = self._get(endpoint.rstrip("/") + "/v1/models", timeout)++ except (OSError, ValueError, urllib.error.URLError):++ return None++ data = doc.get("data") if isinstance(doc, dict) else None++ if not isinstance(data, list):++ return None++ return [str(m.get("id")) for m in data if isinstance(m, dict) and m.get("id")][:100]++++ def _configure(self, config):++ cfg = config or {}++ self.cfg = cfg++ self.max_tokens = _int(cfg.get("max_tokens"), "max_tokens", 1, 32768, 2048)++ self.timeout_s = _int(cfg.get("timeout_s"), "timeout_s", 5, 900, 180)++ endpoint = cfg.get("endpoint")++ if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):++ raise Fault("bad_value", "endpoint is the model server's http(s) base, for example http://127.0.0.1:11434")++ listed = None++ self.reachable = False++ for candidate in ([endpoint] if endpoint else DEFAULT_ENDPOINTS):++ listed = self._list_models(candidate)++ if listed is not None:++ self.endpoint = candidate.rstrip("/")++ self.reachable = True++ break++ else:++ self.endpoint = endpoint.rstrip("/") if endpoint else None++ wanted = cfg.get("models")++ if wanted is not None and (not isinstance(wanted, list) or not all(isinstance(m, str) and m for m in wanted)):++ raise Fault("bad_value", "models is a list of model names")++ self.models = [m for m in (listed or []) if wanted is None or m in wanted] if listed is not None else list(wanted or [])++++ def describe(self, config):++ try:++ self._configure(config)++ except Fault:++ return {}++ return {"ai": {"models": self.models, "ready": self.reachable and bool(self.models),++ "max_tokens": self.max_tokens}}++++ def setup(self, config):++ self._configure(config)++ if self.endpoint is None:++ self.log("no model server answers yet (Ollama, LM Studio or llama.cpp's server, or the endpoint in config)")++++ # ---- one reply --------------------------------------------------------------------------------------------------++ def handle(self, cmd):++ if cmd.kind != "ai":++ raise Fault("unsupported", "the ai plugin takes ai commands only")++ v = cmd.value if isinstance(cmd.value, dict) else {}++ model = v.get("model")++ messages = v.get("messages")++ if not isinstance(model, str) or not model or len(model) > 200:++ raise Fault("bad_value", "model is the name of a model this machine offers")++ if not isinstance(messages, list) or not messages or len(messages) > MAX_MESSAGES or not all(++ isinstance(m, dict) and m.get("role") in ROLES and isinstance(m.get("content"), str)++ and len(m["content"]) <= MAX_CONTENT for m in messages):++ raise Fault("bad_value", "messages are 1 to %d of {role: system|user|assistant, content: text}" % MAX_MESSAGES)++ if not self.reachable:++ self._configure_again()++ if not self.reachable:++ raise Fault("not_ready", "no model server answers on this machine (start Ollama, LM Studio or llama.cpp's server)")++ if model not in self.models:++ self._configure_again()++ if model not in self.models:++ raise Fault("bad_value", "this machine does not offer %s (it offers: %s)" % (model, ", ".join(self.models[:20]) or "none"))++ max_tokens = _int(v.get("max_tokens"), "max_tokens", 1, self.max_tokens, self.max_tokens)++ temperature = v.get("temperature")++ if temperature is not None and (isinstance(temperature, bool) or not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2):++ raise Fault("bad_value", "temperature is 0 to 2")++ if not self.busy.acquire(blocking=False):++ raise Fault("not_ready", "the model is answering another request on this machine: try again in a moment")++ body = {"model": model, "messages": [{"role": m["role"], "content": m["content"]} for m in messages],++ "max_tokens": max_tokens, "stream": False}++ if temperature is not None:++ body["temperature"] = temperature++ threading.Thread(target=self._reply, args=(cmd.id, body), name="ai-reply", daemon=True).start()++ raise Deferred()++++ def _configure_again(self):++ try:++ self._configure(self.cfg)++ except Fault:++ pass++++ def _reply(self, cid, body):++ try:++ req = urllib.request.Request(self.endpoint + "/v1/chat/completions", data=json.dumps(body).encode("utf-8"),++ headers={"Content-Type": "application/json", "Accept": "application/json"})++ started = self.clock()++ with self.opener(req, timeout=self.timeout_s) as r:++ doc = json.loads(r.read(4 * 1024 * 1024).decode("utf-8"))++ choice = (doc.get("choices") or [{}])[0] if isinstance(doc, dict) else {}++ msg = choice.get("message") if isinstance(choice, dict) else None++ content = msg.get("content") if isinstance(msg, dict) else None++ if not isinstance(content, str):++ return self.fail(cid, "hardware", "the model server gave no reply")++ out = {"model": str(doc.get("model") or body["model"]),++ "message": {"role": "assistant", "content": content[:MAX_REPLY_CHARS]},++ "ms": int((self.clock() - started) * 1000)}++ if isinstance(choice.get("finish_reason"), str):++ out["finish_reason"] = choice["finish_reason"]++ usage = doc.get("usage")++ if isinstance(usage, dict):++ out["usage"] = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens", "total_tokens")++ if isinstance(usage.get(k), (int, float))}++ self.complete(cid, out)++ except urllib.error.HTTPError as e:++ self.fail(cid, "hardware", "the model server answered %d" % e.code)++ except (OSError, ValueError, urllib.error.URLError) as e:++ self.fail(cid, "hardware", "the model server did not answer: %s" % (getattr(e, "reason", None) or e))++ finally:++ self.busy.release()nnew file mode 100644@@@ -0,0 +1,6 @@++import sys++++from openvibe_plugin import run++from openvibe_ai import Ai++++sys.exit(run(Ai()))nnew file mode 100644@@@ -0,0 +1,14 @@++[build-system]++requires = ["setuptools>=61"]++build-backend = "setuptools.build_meta"++++[project]++name = "openvibe-ai"++version = "0.1.0"++description = "OpenVibe Node ai plugin: your own models (Ollama, llama.cpp, LM Studio) for your own OpenVibe work, relayed from OpenVibe with no open port"++requires-python = ">=3.9"++license = { text = "AGPL-3.0-or-later" }++dependencies = ["openvibe-plugin"]++++[tool.setuptools]++packages = ["openvibe_ai"]nnew file mode 100644@@@ -0,0 +1,151 @@++"""The ai plugin (openvibe_ai): the person's own model server, reached from OpenVibe with no open port. Against a fake++OpenAI-compatible server: it offers the server's models, relays one chat completion and answers the reply, refuses a++model the machine does not offer and a second request while one runs, and says so when no server answers."""++import http.server++import io++import json++import os++import socketserver++import sys++import threading++import time++import unittest++++HERE = os.path.dirname(os.path.abspath(__file__))++sys.path.insert(0, os.path.dirname(HERE))++sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))++++from openvibe_plugin import Runtime # noqa: E402++from openvibe_ai import Ai # noqa: E402++++++class FakeServer(http.server.BaseHTTPRequestHandler):++ """An OpenAI-compatible model server (as Ollama, LM Studio and llama.cpp's server answer)."""++ seen = []++ hold = threading.Event()++++ def _send(self, status, doc):++ body = json.dumps(doc).encode()++ self.send_response(status)++ self.send_header("Content-Type", "application/json")++ self.send_header("Content-Length", str(len(body)))++ self.end_headers()++ self.wfile.write(body)++++ def do_GET(self):++ if self.path == "/v1/models":++ return self._send(200, {"object": "list", "data": [{"id": "llama3.1:8b"}, {"id": "qwen2.5:7b"}, {"id": "broken"}, {"id": "slow"}]})++ self._send(404, {"error": "not found"})++++ def do_POST(self):++ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")++ FakeServer.seen.append((self.path, body))++ if body.get("model") == "slow":++ FakeServer.hold.wait(5)++ if self.path != "/v1/chat/completions":++ return self._send(404, {"error": "not found"})++ if body.get("model") == "broken":++ return self._send(500, {"error": "out of memory"})++ last = body["messages"][-1]["content"]++ self._send(200, {"model": body["model"], "choices": [{"index": 0, "finish_reason": "stop",++ "message": {"role": "assistant", "content": "You said: " + last}}],++ "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}})++++ def log_message(self, *a):++ pass++++++class Out(io.StringIO):++ def msgs(self):++ return [json.loads(x) for x in self.getvalue().splitlines() if x]++++ def wait_for(self, pred, timeout=10):++ end = time.monotonic() + timeout++ while time.monotonic() < end:++ found = [m for m in self.msgs() if pred(m)]++ if found:++ return found[0]++ time.sleep(0.02)++ raise AssertionError("no such message: %s" % self.getvalue()[-500:])++++++class AiPluginTest(unittest.TestCase):++ def setUp(self):++ self.srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), FakeServer)++ self.srv.daemon_threads = True++ threading.Thread(target=self.srv.serve_forever, daemon=True).start()++ self.endpoint = "http://127.0.0.1:%d" % self.srv.server_address[1]++ FakeServer.seen = []++ FakeServer.hold.clear()++++ def tearDown(self):++ FakeServer.hold.set()++ self.srv.shutdown()++++ def boot(self, **config):++ self.out = Out()++ self.p = Ai()++ self.p.log = lambda *a: None++ self.rt = Runtime(self.p, infile=io.StringIO(""), outfile=self.out)++ self.rt.feed_line(json.dumps({"op": "hello", "config": {"endpoint": self.endpoint, **config}}))++ self.n = 0++ return self.out.msgs()++++ def ask(self, value):++ self.n += 1++ cid = "a%d" % self.n++ self.rt.feed_line(json.dumps({"op": "command", "id": cid, "kind": "ai", "value": value, "deadline_ms": 30000}))++ return self.out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))++++ def test_offers_the_servers_models_and_relays_one_reply(self):++ caps = self.boot()[0]["capabilities"]["ai"]++ self.assertEqual((caps["models"][:2], caps["ready"], caps["max_tokens"]), (["llama3.1:8b", "qwen2.5:7b"], True, 2048))++ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "hello"}],++ "max_tokens": 50, "temperature": 0.2})++ self.assertEqual(r["op"], "ack", r)++ self.assertEqual(r["data"]["message"], {"role": "assistant", "content": "You said: hello"})++ self.assertEqual((r["data"]["model"], r["data"]["finish_reason"]), ("llama3.1:8b", "stop"))++ self.assertEqual(r["data"]["usage"], {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16})++ path, body = FakeServer.seen[-1]++ self.assertEqual(path, "/v1/chat/completions")++ self.assertEqual((body["max_tokens"], body["temperature"], body["stream"]), (50, 0.2, False))++++ def test_a_config_can_offer_fewer_models_and_cap_the_reply(self):++ caps = self.boot(models=["qwen2.5:7b"], max_tokens=100)[0]["capabilities"]["ai"]++ self.assertEqual(caps["models"], ["qwen2.5:7b"])++ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})++ self.assertEqual((r["op"], r["fault_code"]), ("nack", "bad_value"), "a model the machine does not offer")++ self.ask({"model": "qwen2.5:7b", "messages": [{"role": "user", "content": "x"}], "max_tokens": 5000})++ self.assertEqual(FakeServer.seen[-1][1]["max_tokens"], 100, "capped by the owner's max_tokens")++++ def test_refusals(self):++ self.boot(models=["llama3.1:8b", "broken", "slow"])++ bad = [++ {"model": "llama3.1:8b", "messages": []},++ {"model": "llama3.1:8b", "messages": [{"role": "tool", "content": "x"}]},++ {"model": "", "messages": [{"role": "user", "content": "x"}]},++ {"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}], "temperature": 5},++ ]++ for value in bad:++ self.assertEqual(self.ask(value)["fault_code"], "bad_value", value)++ self.assertEqual(self.ask({"model": "broken", "messages": [{"role": "user", "content": "x"}]})["fault_code"], "hardware")++ # One at a time: a second request while one runs is refused, not queued.++ self.n += 1++ self.rt.feed_line(json.dumps({"op": "command", "id": "slow1", "kind": "ai", "value": {"model": "slow", "messages": [{"role": "user", "content": "x"}]}, "deadline_ms": 30000}))++ time.sleep(0.2)++ self.assertEqual(self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})["fault_code"], "not_ready")++ FakeServer.hold.set()++ self.assertEqual(self.out.wait_for(lambda m: m.get("id") == "slow1" and m.get("op") in ("ack", "nack"))["op"], "ack")++ self.assertEqual(self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "again"}]})["op"], "ack", "free again")++++ def test_no_server_answers(self):++ self.srv.shutdown()++ self.srv.server_close()++ caps = self.boot()[0]["capabilities"]["ai"]++ self.assertEqual((caps["models"], caps["ready"]), ([], False))++ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})++ self.assertEqual(r["fault_code"], "not_ready")++++++if __name__ == "__main__":++ unittest.main()@@@ -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.26.0"++__version__ = "0.27.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.26.0"++version = "0.27.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.26.0")++ self.assertEqual(openvibe_plugin.__version__, "0.27.0")with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())@@@ -24,7 +24,7 @@ done# The plugin bundle: the Python packages without their tests.tar -czf dist/openvibe-node-plugins.tar.gz -C plugins \--exclude=tests --exclude=__pycache__ --exclude='*.egg-info' --exclude=.pytest_cache --exclude=build \-- sdk dryrun adeept_adr036 cozmo relay ros2 computer voice++ sdk dryrun adeept_adr036 cozmo relay ros2 computer voice ai# OpenVibe.Bot's GET /install route and BOT_INSTALLER_SOURCE_URL are documented to serve this asset name:# https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh — do not rename it.cp install/install.sh dist/install.sh
@@ -61,6 +61,10 @@ your answer in the tray, a notification's Approve button or `openvibe-node appro
@@ -61,6 +61,10 @@ your answer in the tray, a notification's Approve button or `openvibe-node appro
With it on, you can also watch the machine's screen live on openvibe.bot. Only you see it, it runs for up to 30
With it on, you can also watch the machine's screen live on openvibe.bot. Only you see it, it runs for up to 30
minutes, and `openvibe-node stop` ends it at once ([the live screen](docs/computer.md#the-live-screen-screen_stream-node-v0120)).
minutes, and `openvibe-node stop` ends it at once ([the live screen](docs/computer.md#the-live-screen-screen_stream-node-v0120)).
+**Your own models for your OpenVibe work:** the `ai` plugin lets OpenVibe use a model you already run at home (Ollama,
+LM Studio or llama.cpp's server) for your own work, at no cost and with no open port, once you turn on "Let my AI use
+this computer's models" on openvibe.bot/machines ([docs/ai.md](docs/ai.md)).
+
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
@@ -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.26.0",
- "release": "v0.26.0, published 2026-10-27 as a GitHub release (programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: 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",
+ "version": "0.27.0",
+ "release": "v0.27.0, published 2026-10-27 as a GitHub release (your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: 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,
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+# Your own models for your OpenVibe work
+
+If you already run a model at home (Ollama, LM Studio or llama.cpp's server on a gaming PC or a spare box with a GPU),
+OpenVibe can use it for your own work: Vibe's answers, an agent's thinking, a summary. It costs nothing, and the prompt
+never leaves your network. The machine opens no port: its OpenVibe Node already connects out to OpenVibe.Bot, and the
+request comes back over that connection.
+
+## How it works
+
+1. OpenVibe.AI, on your behalf, asks OpenVibe.Bot to run one chat completion on your machine (`bot.command@1` kind
+ `ai`, contracts 0.219.0).
+2. Bot sends it to your Node, but only for your own machine, and only when you turned on **Let my AI use this
+ computer's models** on openvibe.bot/machines. One person's request never goes to another person's machine.
+3. The Node's `ai` plugin relays it to your model server, and the reply goes back the same way.
+
+The plugin reaches one place only: the model server in its config. It reads nothing else on the machine and changes
+nothing there. It runs one request at a time, because a model is heavy; another request is refused as busy, not
+queued. The audit on Bot keeps the model and the token counts, never the text.
+
+## Set it up
+
+Add the plugin to the Node's config (`config.json`):
+
+```json
+{ "plugins": [{ "name": "ai" }] }
+```
+
+With nothing else set, it uses the first of these that answers:
+
+| Server | Address |
+|---|---|
+| Ollama | `http://127.0.0.1:11434` |
+| LM Studio | `http://127.0.0.1:1234` |
+| llama.cpp's server | `http://127.0.0.1:8080` |
+
+It offers every model the server lists. Restart the Node (`openvibe-node restart`), then turn on **Let my AI use this
+computer's models** for the machine on openvibe.bot/machines.
+
+| key | default | meaning |
+|---|---|---|
+| `endpoint` | the first that answers above | the model server's OpenAI-compatible base, without `/v1` |
+| `models` | everything the server lists | the models you offer (a list) |
+| `max_tokens` | `2048` | the most one reply may be (at most 32768) |
+| `timeout_s` | `180` | how long one reply may take (at most 900) |
+
+## The command
+
+`ai` `{"model", "messages": [{"role": "system"|"user"|"assistant", "content"}], "max_tokens"?, "temperature"?}` →
+`{model, message: {role: "assistant", content}, usage?, finish_reason?, ms}`.
+
+- At most 200 messages of 64 KB each. A model the machine does not offer is refused (`bad_value`), and so is a request
+ while another runs (`not_ready`).
+- When no model server answers, the command is `not_ready` and `describe` says `ai.ready: false`.
+- It never acts on the machine, so it also runs while the machine's stop is latched.
@@ -344,6 +344,7 @@ var Bundled = map[string]string{
@@ -344,6 +344,7 @@ var Bundled = map[string]string{
"ros2": "openvibe_ros2",
"ros2": "openvibe_ros2",
"computer": "openvibe_computer",
"computer": "openvibe_computer",
"voice": "openvibe_voice",
"voice": "openvibe_voice",
+ "ai": "openvibe_ai",
}
}
// Default is the config written by `openvibe-node pair` when none exists: the dry-run plugin and its test pattern.
// Default is the config written by `openvibe-node pair` when none exists: the dry-run plugin and its test pattern.
@@ -25,6 +25,7 @@ var capabilityFor = map[string]string{
@@ -25,6 +25,7 @@ var capabilityFor = map[string]string{
protocol.KindInput: "input",
protocol.KindInput: "input",
protocol.KindBrowser: "browser",
protocol.KindBrowser: "browser",
protocol.KindDesk: "desk",
protocol.KindDesk: "desk",
+ protocol.KindAI: "ai",
}
}
// deskKinds are the kinds a plugin's desks take too. A desk host (install.sh --desk-host) has no screen, mouse or
// deskKinds are the kinds a plugin's desks take too. A desk host (install.sh --desk-host) has no screen, mouse or
@@ -70,6 +70,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {
@@ -70,6 +70,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {
if mode == "deskhost" { // a desk host's computer plugin: desks and files, no screen or input of its own
if mode == "deskhost" { // a desk host's computer plugin: desks and files, no screen or input of its own
caps = map[string]any{"desk": map[string]any{"screen": true, "input": true}, "files": map[string]any{}}
caps = map[string]any{"desk": map[string]any{"screen": true, "input": true}, "files": map[string]any{}}
}
}
+ if mode == "aihost" { // the ai plugin: the person's own models
+ caps = map[string]any{"ai": map[string]any{"models": []string{"llama3.1:8b"}, "ready": true}}
+ }
w.Encode(map[string]any{"op": "describe", "driver": "fake", "version": "1", "capabilities": caps})
w.Encode(map[string]any{"op": "describe", "driver": "fake", "version": "1", "capabilities": caps})
if m["probe"] == true {
if m["probe"] == true {
continue
continue
@@ -285,6 +288,20 @@ func TestRoutingOnADeskHost(t *testing.T) {
@@ -285,6 +288,20 @@ func TestRoutingOnADeskHost(t *testing.T) {
}
}
}
}
+// Kind ai (contracts 0.219.0) reaches the plugin that offers "ai", and only it.
+func TestRoutingAIToTheModelPlugin(t *testing.T) {
+ fx := start(t, "aihost")
+ fx.waitState(StateReady)
+ if fx.m.Route("ai", "", "") == nil {
+ t.Fatal("ai not routed to the plugin that offers it")
+ }
+ for _, kind := range []string{"exec", "screen", "drive"} {
+ if fx.m.Route(kind, "", "") != nil {
+ t.Fatalf("%s routed to the ai plugin", kind)
+ }
+ }
+}
+
func TestShutdownStopsAndClosesStdin(t *testing.T) {
func TestShutdownStopsAndClosesStdin(t *testing.T) {
fx := start(t, "")
fx := start(t, "")
fx.waitState(StateReady)
fx.waitState(StateReady)
@@ -188,13 +188,17 @@ const (
@@ -188,13 +188,17 @@ const (
// "status", "width"?, "height"?}, an Xvfb display of its own where an agent works without touching the owner's
// "status", "width"?, "height"?}, an Xvfb display of its own where an agent works without touching the owner's
// screen; screen, screen_stream, input and browser take "desk": "private" to act there. Starting and stopping act.
// screen; screen, screen_stream, input and browser take "desk": "private" to act there. Starting and stopping act.
KindDesk = "desk"
KindDesk = "desk"
+ // KindAI is one chat completion on the person's own model server (contracts 0.219.0, the ai plugin): {"model",
+ // "messages", "max_tokens"?, "temperature"?} → {model, message, usage?}. It reads and changes nothing on the
+ // machine, so it never acts and runs while a stop is latched like a read.
+ KindAI = "ai"
)
)
// Kinds lists every command kind the Node understands.
// Kinds lists every command kind the Node understands.
-var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk}
+var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk, KindAI}
// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.
// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.
-var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true, KindDesk: true}
+var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true, KindDesk: true, KindAI: true}
const (
const (
// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS caps
// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS caps
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+"""OpenVibe Node ai plugin: the person's own models for their own OpenVibe work (contracts 0.219.0, kind ``ai``).
+
+Many people already run models at home (Ollama, llama.cpp's server, LM Studio). This plugin lets OpenVibe use them for
+their owner, for Vibe's answers or an agent's thinking, without the prompt leaving the machine's network and with no
+open port: OpenVibe.AI asks OpenVibe.Bot, Bot sends the Node one ``ai`` command over the connection the Node already
+keeps, and this plugin relays it to the local model server and answers with the reply.
+
+It reaches one place only, the server in its config, and it reads and changes nothing else on the machine. One request
+runs at a time (a model is heavy); another is refused as busy, never queued. The owner turns it on twice: by adding the
+plugin here, and by "Let my AI use this computer's models" on openvibe.bot/machines.
+
+Config (the plugin's block in config.json):
+
+* ``endpoint``: the model server's OpenAI-compatible base, without ``/v1``. By default the first one that answers of
+ Ollama (``http://127.0.0.1:11434``), LM Studio (``http://127.0.0.1:1234``) and llama.cpp's server
+ (``http://127.0.0.1:8080``).
+* ``models``: the models offered (a list); by default every model the server lists.
+* ``max_tokens``: the most a reply may be (default 2048, at most 32768).
+* ``timeout_s``: how long one reply may take (default 180, at most 900).
+
+Command ``ai`` ``{"model", "messages": [{"role", "content"}], "max_tokens"?, "temperature"?}`` →
+``{model, message: {role: "assistant", content}, usage?, finish_reason?}``.
+"""
+
+import json
+import threading
+import time
+import urllib.error
+import urllib.request
+
+from openvibe_plugin import Deferred, Fault, Plugin
+
+VERSION = "0.1.0"
+DEFAULT_ENDPOINTS = ("http://127.0.0.1:11434", "http://127.0.0.1:1234", "http://127.0.0.1:8080")
+MAX_REPLY_CHARS = 200 * 1024 # well inside the core's answer limit (512 KB)
+MAX_MESSAGES = 200
+MAX_CONTENT = 64 * 1024
+ROLES = ("system", "user", "assistant")
+
+
+def _int(v, name, lo, hi, default):
+ if v is None:
+ return default
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ raise Fault("bad_value", "%s must be a number" % name)
+ return int(max(lo, min(hi, v)))
+
+
+class Ai(Plugin):
+ driver = "ai"
+ version = VERSION
+ motion_kinds = frozenset() # it moves nothing
+
+ def __init__(self, opener=None, clock=time.monotonic):
+ self.opener = opener or urllib.request.urlopen
+ self.clock = clock
+ self.endpoint = None
+ self.models = []
+ self.max_tokens = 2048
+ self.timeout_s = 180
+ self.busy = threading.Lock()
+ self.cfg = {}
+ self.reachable = False
+
+ # ---- the model server -----------------------------------------------------------------------------------------
+ def _get(self, url, timeout):
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
+ with self.opener(req, timeout=timeout) as r:
+ return json.loads(r.read(1024 * 1024).decode("utf-8"))
+
+ def _list_models(self, endpoint, timeout=3.0):
+ """The models a server lists (OpenAI-compatible /v1/models), or None when it does not answer."""
+ try:
+ doc = self._get(endpoint.rstrip("/") + "/v1/models", timeout)
+ except (OSError, ValueError, urllib.error.URLError):
+ return None
+ data = doc.get("data") if isinstance(doc, dict) else None
+ if not isinstance(data, list):
+ return None
+ return [str(m.get("id")) for m in data if isinstance(m, dict) and m.get("id")][:100]
+
+ def _configure(self, config):
+ cfg = config or {}
+ self.cfg = cfg
+ self.max_tokens = _int(cfg.get("max_tokens"), "max_tokens", 1, 32768, 2048)
+ self.timeout_s = _int(cfg.get("timeout_s"), "timeout_s", 5, 900, 180)
+ endpoint = cfg.get("endpoint")
+ if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):
+ raise Fault("bad_value", "endpoint is the model server's http(s) base, for example http://127.0.0.1:11434")
+ listed = None
+ self.reachable = False
+ for candidate in ([endpoint] if endpoint else DEFAULT_ENDPOINTS):
+ listed = self._list_models(candidate)
+ if listed is not None:
+ self.endpoint = candidate.rstrip("/")
+ self.reachable = True
+ break
+ else:
+ self.endpoint = endpoint.rstrip("/") if endpoint else None
+ wanted = cfg.get("models")
+ if wanted is not None and (not isinstance(wanted, list) or not all(isinstance(m, str) and m for m in wanted)):
+ raise Fault("bad_value", "models is a list of model names")
+ self.models = [m for m in (listed or []) if wanted is None or m in wanted] if listed is not None else list(wanted or [])
+
+ def describe(self, config):
+ try:
+ self._configure(config)
+ except Fault:
+ return {}
+ return {"ai": {"models": self.models, "ready": self.reachable and bool(self.models),
+ "max_tokens": self.max_tokens}}
+
+ def setup(self, config):
+ self._configure(config)
+ if self.endpoint is None:
+ self.log("no model server answers yet (Ollama, LM Studio or llama.cpp's server, or the endpoint in config)")
+
+ # ---- one reply --------------------------------------------------------------------------------------------------
+ def handle(self, cmd):
+ if cmd.kind != "ai":
+ raise Fault("unsupported", "the ai plugin takes ai commands only")
+ v = cmd.value if isinstance(cmd.value, dict) else {}
+ model = v.get("model")
+ messages = v.get("messages")
+ if not isinstance(model, str) or not model or len(model) > 200:
+ raise Fault("bad_value", "model is the name of a model this machine offers")
+ if not isinstance(messages, list) or not messages or len(messages) > MAX_MESSAGES or not all(
+ isinstance(m, dict) and m.get("role") in ROLES and isinstance(m.get("content"), str)
+ and len(m["content"]) <= MAX_CONTENT for m in messages):
+ raise Fault("bad_value", "messages are 1 to %d of {role: system|user|assistant, content: text}" % MAX_MESSAGES)
+ if not self.reachable:
+ self._configure_again()
+ if not self.reachable:
+ raise Fault("not_ready", "no model server answers on this machine (start Ollama, LM Studio or llama.cpp's server)")
+ if model not in self.models:
+ self._configure_again()
+ if model not in self.models:
+ raise Fault("bad_value", "this machine does not offer %s (it offers: %s)" % (model, ", ".join(self.models[:20]) or "none"))
+ max_tokens = _int(v.get("max_tokens"), "max_tokens", 1, self.max_tokens, self.max_tokens)
+ temperature = v.get("temperature")
+ if temperature is not None and (isinstance(temperature, bool) or not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2):
+ raise Fault("bad_value", "temperature is 0 to 2")
+ if not self.busy.acquire(blocking=False):
+ raise Fault("not_ready", "the model is answering another request on this machine: try again in a moment")
+ body = {"model": model, "messages": [{"role": m["role"], "content": m["content"]} for m in messages],
+ "max_tokens": max_tokens, "stream": False}
+ if temperature is not None:
+ body["temperature"] = temperature
+ threading.Thread(target=self._reply, args=(cmd.id, body), name="ai-reply", daemon=True).start()
+ raise Deferred()
+
+ def _configure_again(self):
+ try:
+ self._configure(self.cfg)
+ except Fault:
+ pass
+
+ def _reply(self, cid, body):
+ try:
+ req = urllib.request.Request(self.endpoint + "/v1/chat/completions", data=json.dumps(body).encode("utf-8"),
+ headers={"Content-Type": "application/json", "Accept": "application/json"})
+ started = self.clock()
+ with self.opener(req, timeout=self.timeout_s) as r:
+ doc = json.loads(r.read(4 * 1024 * 1024).decode("utf-8"))
+ choice = (doc.get("choices") or [{}])[0] if isinstance(doc, dict) else {}
+ msg = choice.get("message") if isinstance(choice, dict) else None
+ content = msg.get("content") if isinstance(msg, dict) else None
+ if not isinstance(content, str):
+ return self.fail(cid, "hardware", "the model server gave no reply")
+ out = {"model": str(doc.get("model") or body["model"]),
+ "message": {"role": "assistant", "content": content[:MAX_REPLY_CHARS]},
+ "ms": int((self.clock() - started) * 1000)}
+ if isinstance(choice.get("finish_reason"), str):
+ out["finish_reason"] = choice["finish_reason"]
+ usage = doc.get("usage")
+ if isinstance(usage, dict):
+ out["usage"] = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens", "total_tokens")
+ if isinstance(usage.get(k), (int, float))}
+ self.complete(cid, out)
+ except urllib.error.HTTPError as e:
+ self.fail(cid, "hardware", "the model server answered %d" % e.code)
+ except (OSError, ValueError, urllib.error.URLError) as e:
+ self.fail(cid, "hardware", "the model server did not answer: %s" % (getattr(e, "reason", None) or e))
+ finally:
+ self.busy.release()
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+import sys
+
+from openvibe_plugin import run
+from openvibe_ai import Ai
+
+sys.exit(run(Ai()))
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "openvibe-ai"
+version = "0.1.0"
+description = "OpenVibe Node ai plugin: your own models (Ollama, llama.cpp, LM Studio) for your own OpenVibe work, relayed from OpenVibe with no open port"
+requires-python = ">=3.9"
+license = { text = "AGPL-3.0-or-later" }
+dependencies = ["openvibe-plugin"]
+
+[tool.setuptools]
+packages = ["openvibe_ai"]
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+"""The ai plugin (openvibe_ai): the person's own model server, reached from OpenVibe with no open port. Against a fake
+OpenAI-compatible server: it offers the server's models, relays one chat completion and answers the reply, refuses a
+model the machine does not offer and a second request while one runs, and says so when no server answers."""
+import http.server
+import io
+import json
+import os
+import socketserver
+import sys
+import threading
+import time
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.dirname(HERE))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))
+
+from openvibe_plugin import Runtime # noqa: E402
+from openvibe_ai import Ai # noqa: E402
+
+
+class FakeServer(http.server.BaseHTTPRequestHandler):
+ """An OpenAI-compatible model server (as Ollama, LM Studio and llama.cpp's server answer)."""
+ seen = []
+ hold = threading.Event()
+
+ def _send(self, status, doc):
+ body = json.dumps(doc).encode()
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ if self.path == "/v1/models":
+ return self._send(200, {"object": "list", "data": [{"id": "llama3.1:8b"}, {"id": "qwen2.5:7b"}, {"id": "broken"}, {"id": "slow"}]})
+ self._send(404, {"error": "not found"})
+
+ def do_POST(self):
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
+ FakeServer.seen.append((self.path, body))
+ if body.get("model") == "slow":
+ FakeServer.hold.wait(5)
+ if self.path != "/v1/chat/completions":
+ return self._send(404, {"error": "not found"})
+ if body.get("model") == "broken":
+ return self._send(500, {"error": "out of memory"})
+ last = body["messages"][-1]["content"]
+ self._send(200, {"model": body["model"], "choices": [{"index": 0, "finish_reason": "stop",
+ "message": {"role": "assistant", "content": "You said: " + last}}],
+ "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}})
+
+ def log_message(self, *a):
+ pass
+
+
+class Out(io.StringIO):
+ def msgs(self):
+ return [json.loads(x) for x in self.getvalue().splitlines() if x]
+
+ def wait_for(self, pred, timeout=10):
+ end = time.monotonic() + timeout
+ while time.monotonic() < end:
+ found = [m for m in self.msgs() if pred(m)]
+ if found:
+ return found[0]
+ time.sleep(0.02)
+ raise AssertionError("no such message: %s" % self.getvalue()[-500:])
+
+
+class AiPluginTest(unittest.TestCase):
+ def setUp(self):
+ self.srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), FakeServer)
+ self.srv.daemon_threads = True
+ threading.Thread(target=self.srv.serve_forever, daemon=True).start()
+ self.endpoint = "http://127.0.0.1:%d" % self.srv.server_address[1]
+ FakeServer.seen = []
+ FakeServer.hold.clear()
+
+ def tearDown(self):
+ FakeServer.hold.set()
+ self.srv.shutdown()
+
+ def boot(self, **config):
+ self.out = Out()
+ self.p = Ai()
+ self.p.log = lambda *a: None
+ self.rt = Runtime(self.p, infile=io.StringIO(""), outfile=self.out)
+ self.rt.feed_line(json.dumps({"op": "hello", "config": {"endpoint": self.endpoint, **config}}))
+ self.n = 0
+ return self.out.msgs()
+
+ def ask(self, value):
+ self.n += 1
+ cid = "a%d" % self.n
+ self.rt.feed_line(json.dumps({"op": "command", "id": cid, "kind": "ai", "value": value, "deadline_ms": 30000}))
+ return self.out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))
+
+ def test_offers_the_servers_models_and_relays_one_reply(self):
+ caps = self.boot()[0]["capabilities"]["ai"]
+ self.assertEqual((caps["models"][:2], caps["ready"], caps["max_tokens"]), (["llama3.1:8b", "qwen2.5:7b"], True, 2048))
+ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "hello"}],
+ "max_tokens": 50, "temperature": 0.2})
+ self.assertEqual(r["op"], "ack", r)
+ self.assertEqual(r["data"]["message"], {"role": "assistant", "content": "You said: hello"})
+ self.assertEqual((r["data"]["model"], r["data"]["finish_reason"]), ("llama3.1:8b", "stop"))
+ self.assertEqual(r["data"]["usage"], {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16})
+ path, body = FakeServer.seen[-1]
+ self.assertEqual(path, "/v1/chat/completions")
+ self.assertEqual((body["max_tokens"], body["temperature"], body["stream"]), (50, 0.2, False))
+
+ def test_a_config_can_offer_fewer_models_and_cap_the_reply(self):
+ caps = self.boot(models=["qwen2.5:7b"], max_tokens=100)[0]["capabilities"]["ai"]
+ self.assertEqual(caps["models"], ["qwen2.5:7b"])
+ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})
+ self.assertEqual((r["op"], r["fault_code"]), ("nack", "bad_value"), "a model the machine does not offer")
+ self.ask({"model": "qwen2.5:7b", "messages": [{"role": "user", "content": "x"}], "max_tokens": 5000})
+ self.assertEqual(FakeServer.seen[-1][1]["max_tokens"], 100, "capped by the owner's max_tokens")
+
+ def test_refusals(self):
+ self.boot(models=["llama3.1:8b", "broken", "slow"])
+ bad = [
+ {"model": "llama3.1:8b", "messages": []},
+ {"model": "llama3.1:8b", "messages": [{"role": "tool", "content": "x"}]},
+ {"model": "", "messages": [{"role": "user", "content": "x"}]},
+ {"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}], "temperature": 5},
+ ]
+ for value in bad:
+ self.assertEqual(self.ask(value)["fault_code"], "bad_value", value)
+ self.assertEqual(self.ask({"model": "broken", "messages": [{"role": "user", "content": "x"}]})["fault_code"], "hardware")
+ # One at a time: a second request while one runs is refused, not queued.
+ self.n += 1
+ self.rt.feed_line(json.dumps({"op": "command", "id": "slow1", "kind": "ai", "value": {"model": "slow", "messages": [{"role": "user", "content": "x"}]}, "deadline_ms": 30000}))
+ time.sleep(0.2)
+ self.assertEqual(self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})["fault_code"], "not_ready")
+ FakeServer.hold.set()
+ self.assertEqual(self.out.wait_for(lambda m: m.get("id") == "slow1" and m.get("op") in ("ack", "nack"))["op"], "ack")
+ self.assertEqual(self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "again"}]})["op"], "ack", "free again")
+
+ def test_no_server_answers(self):
+ self.srv.shutdown()
+ self.srv.server_close()
+ caps = self.boot()[0]["capabilities"]["ai"]
+ self.assertEqual((caps["models"], caps["ready"]), ([], False))
+ r = self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "x"}]})
+ self.assertEqual(r["fault_code"], "not_ready")
+
+
+if __name__ == "__main__":
+ unittest.main()
@@ -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.26.0"
+__version__ = "0.27.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.26.0"
+version = "0.27.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.26.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.27.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())
@@ -24,7 +24,7 @@ done
@@ -24,7 +24,7 @@ done
# The plugin bundle: the Python packages without their tests.
# The plugin bundle: the Python packages without their tests.
tar -czf dist/openvibe-node-plugins.tar.gz -C plugins \
tar -czf dist/openvibe-node-plugins.tar.gz -C plugins \
--exclude=tests --exclude=__pycache__ --exclude='*.egg-info' --exclude=.pytest_cache --exclude=build \
--exclude=tests --exclude=__pycache__ --exclude='*.egg-info' --exclude=.pytest_cache --exclude=build \
- sdk dryrun adeept_adr036 cozmo relay ros2 computer voice
+ sdk dryrun adeept_adr036 cozmo relay ros2 computer voice ai
# OpenVibe.Bot's GET /install route and BOT_INSTALLER_SOURCE_URL are documented to serve this asset name:
# OpenVibe.Bot's GET /install route and BOT_INSTALLER_SOURCE_URL are documented to serve this asset name:
# https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh — do not rename it.
# https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh — do not rename it.
cp install/install.sh dist/install.sh
cp install/install.sh dist/install.sh