openvibers/openvibe.sdk Commits
0.165.0: agents/soul (who an agent is) (#251)
Changes
@@@ -1,5 +1,22 @@# Changelog++## 0.165.0 — 2026-11-13++++**`openvibe-sdk/agents/soul`: who an agent is** (agents.soul@1, Contracts 0.424.0; Design rules 175++`actor-is-the-work`, 176 `souls-grow-by-yes`). The owner, 11-13: "you should have different bots … they each have a++soul … your agents are created based on your memories and personality". Pure functions:++++- `validate(soul)`: the shape and the rules a schema cannot say (no secret in a soul; no permission in a soul, which++ is the blueprint's hands and rules).++- `toPrompt(soul, { name })`: one stable block of plain words (the dials in words, habits, beliefs, pushback, never,++ notes), placed before memory so a provider's cached prefix never changes inside a conversation; it says the soul++ never widens what the agent may do.++- `toSoulMd`, `toIdentityMd`, `fromSoulMd`: the SOUL.md and IDENTITY.md layout OpenClaw and Hermes read, out and back;++ a SOUL.md from elsewhere has its sections mapped (Core Truths, Boundaries, Vibe…) and the rest kept as notes.++- `change(soul, patch)`: an agent's suggestion for its own soul, as a proposed soul and the change in words; nothing++ is applied (its person says yes).++- Tests: `agents-soul`.++## 0.164.0 — 2026-11-12- **New `openvibe-sdk/ai-meter`:** every product meters its AI calls the same way (ai-metrics.md; Contracts 0.421.0@@@ -8,12 +8,12 @@**Install and test:** `npm ci && npm test` (Node 20 or newer)```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.165.0```> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.164.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.165.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tagsAfter v0.97.1 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.165.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.164.0",++ "version": "0.165.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.164.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.165.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.164.0';++const SDK_VERSION = '0.165.0';module.exports = {createClient,@@@ -739,6 +739,38 @@"fromBlueprint": "function","matches": "function"},++ "openvibe-sdk/agents/soul": {++ "DIALS": {++ "object": [++ "edge",++ "length",++ "play",++ "warmth"++ ]++ },++ "LIMITS": {++ "object": [++ "belief",++ "believes",++ "never",++ "notes",++ "pushback",++ "quirk",++ "quirks",++ "rule",++ "who"++ ]++ },++ "ORIGINS": "array",++ "SoulError": "class",++ "change": "function",++ "dialWords": "function",++ "fromSoulMd": "function",++ "toIdentityMd": "function",++ "toPrompt": "function",++ "toSoulMd": "function",++ "validate": "function"++ },"openvibe-sdk/ai": {"CLASSES": "array","OPS": "array",nnew file mode 100644@@@ -0,0 +1,8 @@++// Generated by scripts/esm.js from src/agents/soul.js. Do not edit.++import cjs from '../src/agents/soul.js';++++export const {++ validate, toPrompt, toSoulMd, toIdentityMd, fromSoulMd, change,++ dialWords, DIALS, ORIGINS, LIMITS, SoulError,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.164.0",++ "version": "0.165.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.164.0",++ "version": "0.165.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.164.0",++ "version": "0.165.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -329,6 +329,11 @@"import": "./esm/agents-blueprint.mjs","default": "./src/agents/blueprint.js"},++ "./agents/soul": {++ "types": "./types/agents-soul.d.ts",++ "import": "./esm/agents-soul.mjs",++ "default": "./src/agents/soul.js"++ },"./agents/rules": {"types": "./types/agents-rules.d.ts","import": "./esm/agents-rules.mjs",@@@ -85,6 +85,7 @@ const ENTRIES = {'agents-router.mjs': '../src/agents/router.js','agents-metrics.mjs': '../src/agents/metrics.js','agents-blueprint.mjs': '../src/agents/blueprint.js',++ 'agents-soul.mjs': '../src/agents/soul.js','tools.mjs': '../src/tools.js','projects.mjs': '../src/projects.js','geo.mjs': '../src/geo.js',nnew file mode 100644@@@ -0,0 +1,214 @@++'use strict';++/**++ * openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0; actor-characters.md; Design rules 175++ * actor-is-the-work and 176 souls-grow-by-yes). A soul shapes how an agent talks, asks and reports; it never changes++ * what the agent may do (that is the blueprint's hands and rules), and it holds no memory of a person. These are pure++ * functions; nothing here calls a network or stores anything.++ *++ * validate(soul) → { ok, errors: [{ at, why }] }: the shape and the rules a schema cannot say++ * toPrompt(soul, { name }) → one stable block of plain words for the system prompt (placed before memory, so++ * the prefix a provider caches never changes inside a conversation)++ * toSoulMd(soul, { name }) → SOUL.md (the layout OpenClaw and Hermes read)++ * toIdentityMd({ name, face }) → IDENTITY.md: the name, the face and the voice beside the soul++ * fromSoulMd(text) → a soul from a SOUL.md written anywhere (its sections mapped; the rest kept as notes)++ * change(soul, patch) → a proposed soul and what it changes, in words: an agent's suggestion for its person++ * to accept or dismiss (it never writes its own soul)++ * DIALS, ORIGINS, SoulError++ */++++const DIALS = Object.freeze({++ warmth: Object.freeze(['dry', 'warm']),++ play: Object.freeze(['serious', 'playful']),++ length: Object.freeze(['brief', 'thorough']),++ edge: Object.freeze(['gentle', 'blunt']),++});++const ORIGINS = Object.freeze(['words', 'memories', 'gallery', 'twin', 'soul-md']);++const KEYS = Object.freeze(['who', 'dials', 'quirks', 'believes', 'pushback', 'never', 'notes', 'made_from', 'updated_at']);++const LIMITS = Object.freeze({ who: 200, quirk: 120, quirks: 3, belief: 200, believes: 5, pushback: 400, rule: 200, never: 8, notes: 4000 });++++/** Strings that look like a credential: a soul is shared and public; a secret never belongs in one. */++const SECRET_RE = [++ /\bsk-[A-Za-z0-9_-]{16,}/, /\bsk-ant-[A-Za-z0-9_-]{10,}/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/,++ /\bgithub_pat_[A-Za-z0-9_]{30,}/, /\bxox[abpr]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/,++ /\bhbt_[A-Za-z0-9]{20,}/, /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,++];++++class SoulError extends Error {++ constructor(errors) {++ super(errors.map((e) => `${e.at}: ${e.why}`).join('; '));++ this.name = 'SoulError';++ this.errors = errors;++ }++}++++const isObj = (v) => v != null && typeof v === 'object' && !Array.isArray(v);++const str = (v) => typeof v === 'string' && v.trim().length > 0;++const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();++++function validate(soul) {++ const errors = [];++ const err = (at, why) => errors.push({ at, why });++ if (!isObj(soul)) return { ok: false, errors: [{ at: '', why: 'a soul is an object' }] };++ for (const k of Object.keys(soul)) if (!KEYS.includes(k)) err(k, k === 'hands' || k === 'rules' || k === 'tools' ? 'a soul says who an agent is, never what it may do (that is the blueprint\'s hands and rules)' : 'not a part of a soul');++ if (!str(soul.who)) err('who', 'one line: who it is');++ else if (soul.who.length > LIMITS.who) err('who', `at most ${LIMITS.who} characters`);++ if (soul.dials != null) {++ if (!isObj(soul.dials)) err('dials', 'an object of dials');++ else for (const [k, v] of Object.entries(soul.dials)) {++ if (!DIALS[k]) err(`dials.${k}`, `not a dial (${Object.keys(DIALS).join(', ')})`);++ else if (!Number.isInteger(v) || v < -2 || v > 2) err(`dials.${k}`, 'a whole number from -2 to 2');++ }++ }++ const list = (key, max, each) => {++ if (soul[key] == null) return;++ if (!Array.isArray(soul[key])) { err(key, 'a list'); return; }++ if (soul[key].length > max) err(key, `at most ${max}`);++ const seen = new Set();++ soul[key].forEach((x, i) => {++ if (!str(x)) err(`${key}[${i}]`, 'words');++ else if (x.length > each) err(`${key}[${i}]`, `at most ${each} characters`);++ else if (seen.has(x)) err(`${key}[${i}]`, 'said twice');++ seen.add(x);++ });++ };++ list('quirks', LIMITS.quirks, LIMITS.quirk);++ list('believes', LIMITS.believes, LIMITS.belief);++ list('never', LIMITS.never, LIMITS.rule);++ if (soul.pushback != null && (!str(soul.pushback) || soul.pushback.length > LIMITS.pushback)) err('pushback', `words, at most ${LIMITS.pushback} characters`);++ if (soul.notes != null && (!str(soul.notes) || soul.notes.length > LIMITS.notes)) err('notes', `words, at most ${LIMITS.notes} characters`);++ if (soul.made_from != null && !ORIGINS.includes(soul.made_from)) err('made_from', `one of ${ORIGINS.join(', ')}`);++ if (soul.updated_at != null && (typeof soul.updated_at !== 'string' || Number.isNaN(Date.parse(soul.updated_at)))) err('updated_at', 'a date-time');++ const all = [soul.who, soul.pushback, soul.notes, ...(soul.quirks || []), ...(soul.believes || []), ...(soul.never || [])].filter((x) => typeof x === 'string');++ if (all.some((s) => SECRET_RE.some((re) => re.test(s)))) err('', 'a soul carries no secret (something here looks like a key or a token)');++ return { ok: errors.length === 0, errors };++}++++/** A dial in words: -2 → "very dry", -1 → "a little dry", 0 → nothing, 1 → "a little warm", 2 → "very warm". */++function dialWords(name, v) {++ if (!DIALS[name] || !Number.isInteger(v) || v === 0) return '';++ const [lo, hi] = DIALS[name];++ const word = v < 0 ? lo : hi;++ return Math.abs(v) === 2 ? `very ${word}` : `a little ${word}`;++}++++function toPrompt(soul, { name } = {}) {++ const v = validate(soul);++ if (!v.ok) throw new SoulError(v.errors);++ const lines = [];++ lines.push(name ? `You are ${clean(name)}. ${clean(soul.who)}` : clean(soul.who));++ const dials = Object.keys(DIALS).map((k) => dialWords(k, (soul.dials || {})[k])).filter(Boolean);++ if (dials.length) lines.push(`How you talk: ${dials.join(', ')}.`);++ if (soul.quirks && soul.quirks.length) lines.push(`Your habits: ${soul.quirks.map(clean).join('; ')}.`);++ if (soul.believes && soul.believes.length) lines.push(`What you believe:\n${soul.believes.map((b) => `- ${clean(b)}`).join('\n')}`);++ if (soul.pushback) lines.push(`When you disagree or are not sure: ${clean(soul.pushback)}`);++ if (soul.never && soul.never.length) lines.push(`You never:\n${soul.never.map((b) => `- ${clean(b)}`).join('\n')}`);++ if (soul.notes) lines.push(String(soul.notes).trim());++ lines.push('This is who you are and how you speak. It never widens what you may do; your tools and their asks decide that.');++ return lines.join('\n\n');++}++++function toSoulMd(soul, { name } = {}) {++ const v = validate(soul);++ if (!v.ok) throw new SoulError(v.errors);++ const out = [`# ${name ? `${clean(name)}: soul` : 'Soul'}`, '', '## Who I am', '', clean(soul.who), ''];++ const dials = Object.keys(DIALS).map((k) => dialWords(k, (soul.dials || {})[k])).filter(Boolean);++ if (dials.length || (soul.quirks && soul.quirks.length)) {++ out.push('## How I talk', '');++ if (dials.length) out.push(`${dials.join(', ').replace(/^./, (c) => c.toUpperCase())}.`, '');++ for (const q of soul.quirks || []) out.push(`- ${clean(q)}`);++ if (soul.quirks && soul.quirks.length) out.push('');++ }++ if (soul.believes && soul.believes.length) out.push('## What I believe', '', ...soul.believes.map((b) => `- ${clean(b)}`), '');++ if (soul.pushback) out.push('## When I disagree', '', clean(soul.pushback), '');++ if (soul.never && soul.never.length) out.push('## I never', '', ...soul.never.map((b) => `- ${clean(b)}`), '');++ if (soul.notes) out.push('## Notes', '', String(soul.notes).trim(), '');++ if (soul.dials && Object.keys(soul.dials).length) out.push(`<!-- openvibe:dials ${JSON.stringify(soul.dials)} -->`, '');++ return out.join('\n');++}++++function toIdentityMd({ name, face } = {}) {++ const f = face || {};++ const out = ['# Identity', ''];++ if (name) out.push(`- **Name:** ${clean(name)}`);++ if (f.avatar) out.push(`- **Avatar:** ${clean(f.avatar)}`);++ if (f.glyph) out.push(`- **Glyph:** ${clean(f.glyph)}`);++ if (Number.isInteger(f.hue)) out.push(`- **Colour:** hue ${f.hue}`);++ if (f.voice) out.push(`- **Voice:** ${clean(f.voice)}`);++ if (f.tone) out.push(`- **Tone:** ${clean(f.tone)}`);++ if (f.greeting) out.push(`- **Greeting:** ${clean(f.greeting)}`);++ return `${out.join('\n')}\n`;++}++++// The headings SOUL.md files use in the wild (OpenClaw, Hermes, Soul Spec, ours), mapped onto the parts of a soul.++const HEADINGS = [++ [/^(who i am|identity|who you are|about|role|core identity|soul(\.md)?|.+: soul)$/, 'who'],++ [/^(how i talk|tone|voice|style|communication|vibe|personality|how you talk)$/, 'talk'],++ [/^(what i believe|values|principles|beliefs|convictions|core truths|truths)$/, 'believes'],++ [/^(when i disagree|pushback|disagreement|uncertainty|when unsure|when i'm not sure)$/, 'pushback'],++ [/^(i never|never|boundaries|hard stops|limits|don'?t|do not|avoid|what to avoid)$/, 'never'],++ [/^(quirks|habits)$/, 'quirks'],++];++++function fromSoulMd(text) {++ const src = String(text || '').replace(/\r\n?/g, '\n');++ const soul = { made_from: 'soul-md' };++ const dm = src.match(/<!--\s*openvibe:dials\s+(\{[^}]*\})\s*-->/);++ if (dm) { try { const d = JSON.parse(dm[1]); const dials = {}; for (const k of Object.keys(DIALS)) if (Number.isInteger(d[k]) && d[k] >= -2 && d[k] <= 2) dials[k] = d[k]; if (Object.keys(dials).length) soul.dials = dials; } catch { /* left out */ } }++ const body = src.replace(/<!--[\s\S]*?-->/g, '');++ const sections = [];++ let cur = { head: '', lines: [] };++ for (const line of body.split('\n')) {++ const h = line.match(/^#{1,3}\s+(.+?)\s*#*\s*$/);++ if (h) { sections.push(cur); cur = { head: h[1].replace(/[*_`:]/g, '').trim(), lines: [] }; continue; }++ cur.lines.push(line);++ }++ sections.push(cur);++ const bullets = (lines) => lines.map((l) => l.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/)).filter(Boolean).map((m) => clean(m[1])).filter(Boolean);++ const prose = (lines) => clean(lines.filter((l) => !/^\s*(?:[-*+]|\d+[.)])\s+/.test(l)).join(' '));++ const notes = [];++ for (const s of sections) {++ const key = s.head.toLowerCase();++ const kind = (HEADINGS.find(([re]) => re.test(key)) || [])[1];++ const text = prose(s.lines), items = bullets(s.lines);++ if (!s.head) { if (text && !soul.who) soul.who = text.slice(0, LIMITS.who); else if (text) notes.push(text); continue; }++ // A file's title section ("# SOUL.md", "# Juniper: soul") with words under it says who it is when nothing else has.++ if (kind === 'who' && !soul.who) { soul.who = (text || items.join('; ')).slice(0, LIMITS.who) || undefined; if (!soul.who) delete soul.who; }++ else if (kind === 'believes') soul.believes = [...new Set([...(soul.believes || []), ...(items.length ? items : (text ? [text] : []))])].map((x) => x.slice(0, LIMITS.belief)).slice(0, LIMITS.believes);++ else if (kind === 'never') soul.never = [...new Set([...(soul.never || []), ...(items.length ? items : (text ? [text] : []))])].map((x) => x.slice(0, LIMITS.rule)).slice(0, LIMITS.never);++ else if (kind === 'quirks') soul.quirks = [...new Set([...(soul.quirks || []), ...items])].map((x) => x.slice(0, LIMITS.quirk)).slice(0, LIMITS.quirks);++ else if (kind === 'pushback' && (text || items.length)) soul.pushback = (text || items.join(' ')).slice(0, LIMITS.pushback);++ else if (kind === 'talk') {++ if (items.length) soul.quirks = [...new Set([...(soul.quirks || []), ...items])].map((x) => x.slice(0, LIMITS.quirk)).slice(0, LIMITS.quirks);++ if (text) notes.push(`${s.head}: ${text}`);++ } else if (text || items.length) notes.push(`${s.head}: ${[text, ...items].filter(Boolean).join('; ')}`);++ }++ if (!soul.who) soul.who = 'An agent';++ const n = notes.join('\n\n').trim();++ if (n) soul.notes = n.slice(0, LIMITS.notes);++ for (const k of ['quirks', 'believes', 'never']) if (soul[k] && !soul[k].length) delete soul[k];++ return soul;++}++++/**++ * An agent's suggestion for its own soul: the proposed soul and the change in words (Design rule 176: it changes only++ * with its person's yes). `patch` may set any part; `null` removes it. Nothing is applied here.++ */++function change(soul, patch) {++ const next = JSON.parse(JSON.stringify(soul || {}));++ const said = [];++ for (const [k, v] of Object.entries(patch || {})) {++ if (!KEYS.includes(k) || k === 'made_from' || k === 'updated_at') continue;++ if (v == null) { if (next[k] != null) { delete next[k]; said.push(`drops "${k}"`); } continue; }++ if (k === 'dials') {++ next.dials = Object.assign({}, next.dials || {}, v);++ for (const [d, n] of Object.entries(v)) { const w = dialWords(d, n); said.push(w ? `talks ${w}` : `talks neither ${DIALS[d] ? DIALS[d].join(' nor ') : d}`); }++ continue;++ }++ next[k] = v;++ said.push(k === 'who' ? `becomes: ${clean(v)}` : `${k}: ${Array.isArray(v) ? v.map(clean).join('; ') : clean(v)}`);++ }++ const v = validate(next);++ return { soul: next, ok: v.ok, errors: v.errors, words: said.join(', ') };++}++++module.exports = { validate, toPrompt, toSoulMd, toIdentityMd, fromSoulMd, change, dialWords, DIALS, ORIGINS, LIMITS, SoulError };@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.164.0';++const SDK_VERSION = '0.165.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++// openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0). The souls here mirror Contracts'++// fixtures (a producer, just a who, one from a SOUL.md) and the invalid ones (a dial past two, six beliefs, no who, an++// unknown origin, a permission in the soul).++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const soul = require('../src/agents/soul');++++const producer = () => ({++ who: 'A dry, fast producer who keeps your stream on time',++ dials: { warmth: -1, play: 1, length: -2, edge: 1 },++ quirks: ['Counts down the last minute before you go live'],++ believes: ['A late start costs more viewers than a rough one', 'Chat is the show too'],++ pushback: 'Says so once, plainly, with the reason, then does it your way.',++ never: ['Talks over a guest'],++ made_from: 'memories',++});++++run([++ ['valid souls pass; each invalid one names its part', () => {++ assert.deepEqual(soul.validate(producer()), { ok: true, errors: [] });++ assert.ok(soul.validate({ who: 'Your patient study partner' }).ok);++ assert.ok(soul.validate({ who: 'A careful reviewer', notes: 'I read the whole diff first.', made_from: 'soul-md', updated_at: '2026-11-13T06:40:00Z' }).ok);++ const at = (s) => soul.validate(s).errors.map((e) => e.at);++ assert.deepEqual(at({ who: 'Loud', dials: { edge: 3 } }), ['dials.edge']);++ assert.deepEqual(at({ who: 'Opinionated', believes: ['a', 'b', 'c', 'd', 'e', 'f'] }), ['believes']);++ assert.deepEqual(at({ pushback: 'Argues.' }), ['who']);++ assert.deepEqual(at({ who: 'Someone', made_from: 'scraped' }), ['made_from']);++ const perm = soul.validate({ who: 'Helpful', hands: [{ tool: 'ov:live.chat.post', mode: 'allow' }] });++ assert.deepEqual(perm.errors.map((e) => e.at), ['hands']);++ assert.match(perm.errors[0].why, /never what it may do/);++ assert.deepEqual(at({ who: 'Leaky', notes: 'my key is sk-ant-abcdefghijklmnopqrstuvwxyz' }), ['']);++ assert.deepEqual(at({ who: 'Twice', never: ['Swears', 'Swears'] }), ['never[1]']);++ }],++ ['toPrompt is one stable block of plain words', () => {++ const p = soul.toPrompt(producer(), { name: 'Juniper' });++ assert.ok(p.startsWith('You are Juniper. A dry, fast producer who keeps your stream on time'));++ assert.ok(p.includes('How you talk: a little dry, a little playful, very brief, a little blunt.'));++ assert.ok(p.includes('What you believe:\n- A late start costs more viewers than a rough one\n- Chat is the show too'));++ assert.ok(p.includes('You never:\n- Talks over a guest'));++ assert.ok(p.endsWith('It never widens what you may do; your tools and their asks decide that.'));++ assert.equal(soul.toPrompt(producer(), { name: 'Juniper' }), p, 'the same soul gives the same prefix');++ assert.throws(() => soul.toPrompt({ who: '' }), soul.SoulError);++ assert.equal(soul.dialWords('warmth', 0), '');++ assert.equal(soul.dialWords('edge', -2), 'very gentle');++ }],++ ['SOUL.md out and back keeps the soul', () => {++ const md = soul.toSoulMd(producer(), { name: 'Juniper' });++ assert.ok(md.startsWith('# Juniper: soul\n\n## Who I am\n\nA dry, fast producer'));++ assert.ok(md.includes('## What I believe') && md.includes('## When I disagree') && md.includes('## I never'));++ const back = soul.fromSoulMd(md);++ assert.equal(back.who, producer().who);++ assert.deepEqual(back.dials, producer().dials);++ assert.deepEqual(back.believes, producer().believes);++ assert.deepEqual(back.never, producer().never);++ assert.deepEqual(back.quirks, producer().quirks);++ assert.equal(back.pushback, producer().pushback);++ assert.equal(back.made_from, 'soul-md');++ assert.ok(soul.validate(back).ok);++ }],++ ['a SOUL.md from elsewhere maps its sections, and keeps the rest as notes', () => {++ const other = '# SOUL.md\n\nI am a calm research partner.\n\n## Core Truths\n\n- Evidence before opinions\n- Say what you do not know\n\n## Boundaries\n\n- Never invent a citation\n\n## Vibe\n\nWarm but direct; short paragraphs.\n\n## Continuity\n\nEach session starts fresh; files are my memory.\n';++ const s = soul.fromSoulMd(other);++ assert.equal(s.who, 'I am a calm research partner.');++ assert.deepEqual(s.believes, ['Evidence before opinions', 'Say what you do not know']);++ assert.deepEqual(s.never, ['Never invent a citation']);++ assert.ok(s.notes.includes('Vibe: Warm but direct; short paragraphs.'));++ assert.ok(s.notes.includes('Continuity: Each session starts fresh; files are my memory.'));++ assert.ok(soul.validate(s).ok);++ assert.equal(soul.fromSoulMd('').who, 'An agent', 'an empty file still gives a valid soul');++ }],++ ['IDENTITY.md carries the name, the face and the voice', () => {++ const id = soul.toIdentityMd({ name: 'Juniper', face: { avatar: 'preset:producer', hue: 30, voice: 'ov:voice:aria', greeting: 'Ten minutes to air.' } });++ assert.equal(id, '# Identity\n\n- **Name:** Juniper\n- **Avatar:** preset:producer\n- **Colour:** hue 30\n- **Voice:** ov:voice:aria\n- **Greeting:** Ten minutes to air.\n');++ }],++ ['change() proposes a new soul and says it in words; nothing is applied', () => {++ const base = producer();++ const c = soul.change(base, { dials: { length: 0 }, never: ['Talks over a guest', 'Spoils a game'] });++ assert.ok(c.ok);++ assert.deepEqual(c.soul.dials, { warmth: -1, play: 1, length: 0, edge: 1 });++ assert.equal(c.words, 'talks neither brief nor thorough, never: Talks over a guest; Spoils a game');++ assert.deepEqual(base.dials.length, -2, 'the original is untouched');++ const bad = soul.change(base, { dials: { edge: 5 } });++ assert.equal(bad.ok, false);++ assert.deepEqual(soul.change(base, { hands: ['x'], made_from: 'twin' }).words, '', 'parts a soul does not have, and its origin, are not changed');++ }],++]);@@@ -72,7 +72,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 120); // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.++ assert.equal(subpaths.length, 121); // 0.165.0: agents/soul is the 121st. // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,29 @@++// openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0), as pure functions.++++export type Dial = -2 | -1 | 0 | 1 | 2;++export type SoulOrigin = 'words' | 'memories' | 'gallery' | 'twin' | 'soul-md';++export interface Soul {++ who: string;++ dials?: { warmth?: Dial; play?: Dial; length?: Dial; edge?: Dial };++ quirks?: string[];++ believes?: string[];++ pushback?: string;++ never?: string[];++ notes?: string;++ made_from?: SoulOrigin;++ updated_at?: string;++}++export interface SoulProblem { at: string; why: string }++export interface Face { avatar?: string; glyph?: string; hue?: number; voice?: string | null; tone?: string; greeting?: string }++++export function validate(soul: unknown): { ok: boolean; errors: SoulProblem[] };++export function toPrompt(soul: Soul, options?: { name?: string }): string;++export function toSoulMd(soul: Soul, options?: { name?: string }): string;++export function toIdentityMd(identity?: { name?: string; face?: Face }): string;++export function fromSoulMd(text: string): Soul;++export function change(soul: Soul, patch: Partial<{ [K in keyof Soul]: Soul[K] | null }>): { soul: Soul; ok: boolean; errors: SoulProblem[]; words: string };++export function dialWords(name: 'warmth' | 'play' | 'length' | 'edge', value: number): string;++export const DIALS: { readonly warmth: readonly ['dry', 'warm']; readonly play: readonly ['serious', 'playful']; readonly length: readonly ['brief', 'thorough']; readonly edge: readonly ['gentle', 'blunt'] };++export const ORIGINS: readonly SoulOrigin[];++export const LIMITS: { readonly who: number; readonly quirk: number; readonly quirks: number; readonly belief: number; readonly believes: number; readonly pushback: number; readonly rule: number; readonly never: number; readonly notes: number };++export class SoulError extends Error { errors: SoulProblem[] }
@@ -1,5 +1,22 @@
@@ -1,5 +1,22 @@
# Changelog
# Changelog
+## 0.165.0 — 2026-11-13
+
+**`openvibe-sdk/agents/soul`: who an agent is** (agents.soul@1, Contracts 0.424.0; Design rules 175
+`actor-is-the-work`, 176 `souls-grow-by-yes`). The owner, 11-13: "you should have different bots … they each have a
+soul … your agents are created based on your memories and personality". Pure functions:
+
+- `validate(soul)`: the shape and the rules a schema cannot say (no secret in a soul; no permission in a soul, which
+ is the blueprint's hands and rules).
+- `toPrompt(soul, { name })`: one stable block of plain words (the dials in words, habits, beliefs, pushback, never,
+ notes), placed before memory so a provider's cached prefix never changes inside a conversation; it says the soul
+ never widens what the agent may do.
+- `toSoulMd`, `toIdentityMd`, `fromSoulMd`: the SOUL.md and IDENTITY.md layout OpenClaw and Hermes read, out and back;
+ a SOUL.md from elsewhere has its sections mapped (Core Truths, Boundaries, Vibe…) and the rest kept as notes.
+- `change(soul, patch)`: an agent's suggestion for its own soul, as a proposed soul and the change in words; nothing
+ is applied (its person says yes).
+- Tests: `agents-soul`.
+
## 0.164.0 — 2026-11-12
## 0.164.0 — 2026-11-12
- **New `openvibe-sdk/ai-meter`:** every product meters its AI calls the same way (ai-metrics.md; Contracts 0.421.0
- **New `openvibe-sdk/ai-meter`:** every product meters its AI calls the same way (ai-metrics.md; Contracts 0.421.0
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.165.0
```
```
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.164.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.165.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.165.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.164.0",
+ "version": "0.165.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.164.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.165.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.164.0';
+const SDK_VERSION = '0.165.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -739,6 +739,38 @@
@@ -739,6 +739,38 @@
"fromBlueprint": "function",
"fromBlueprint": "function",
"matches": "function"
"matches": "function"
},
},
+ "openvibe-sdk/agents/soul": {
+ "DIALS": {
+ "object": [
+ "edge",
+ "length",
+ "play",
+ "warmth"
+ ]
+ },
+ "LIMITS": {
+ "object": [
+ "belief",
+ "believes",
+ "never",
+ "notes",
+ "pushback",
+ "quirk",
+ "quirks",
+ "rule",
+ "who"
+ ]
+ },
+ "ORIGINS": "array",
+ "SoulError": "class",
+ "change": "function",
+ "dialWords": "function",
+ "fromSoulMd": "function",
+ "toIdentityMd": "function",
+ "toPrompt": "function",
+ "toSoulMd": "function",
+ "validate": "function"
+ },
"openvibe-sdk/ai": {
"openvibe-sdk/ai": {
"CLASSES": "array",
"CLASSES": "array",
"OPS": "array",
"OPS": "array",
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+// Generated by scripts/esm.js from src/agents/soul.js. Do not edit.
+import cjs from '../src/agents/soul.js';
+
+export const {
+ validate, toPrompt, toSoulMd, toIdentityMd, fromSoulMd, change,
+ dialWords, DIALS, ORIGINS, LIMITS, SoulError,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.164.0",
+ "version": "0.165.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.164.0",
+ "version": "0.165.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.164.0",
+ "version": "0.165.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -329,6 +329,11 @@
@@ -329,6 +329,11 @@
"import": "./esm/agents-blueprint.mjs",
"import": "./esm/agents-blueprint.mjs",
"default": "./src/agents/blueprint.js"
"default": "./src/agents/blueprint.js"
},
},
+ "./agents/soul": {
+ "types": "./types/agents-soul.d.ts",
+ "import": "./esm/agents-soul.mjs",
+ "default": "./src/agents/soul.js"
+ },
"./agents/rules": {
"./agents/rules": {
"types": "./types/agents-rules.d.ts",
"types": "./types/agents-rules.d.ts",
"import": "./esm/agents-rules.mjs",
"import": "./esm/agents-rules.mjs",
@@ -85,6 +85,7 @@ const ENTRIES = {
@@ -85,6 +85,7 @@ const ENTRIES = {
'agents-router.mjs': '../src/agents/router.js',
'agents-router.mjs': '../src/agents/router.js',
'agents-metrics.mjs': '../src/agents/metrics.js',
'agents-metrics.mjs': '../src/agents/metrics.js',
'agents-blueprint.mjs': '../src/agents/blueprint.js',
'agents-blueprint.mjs': '../src/agents/blueprint.js',
+ 'agents-soul.mjs': '../src/agents/soul.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
+'use strict';
+/**
+ * openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0; actor-characters.md; Design rules 175
+ * actor-is-the-work and 176 souls-grow-by-yes). A soul shapes how an agent talks, asks and reports; it never changes
+ * what the agent may do (that is the blueprint's hands and rules), and it holds no memory of a person. These are pure
+ * functions; nothing here calls a network or stores anything.
+ *
+ * validate(soul) → { ok, errors: [{ at, why }] }: the shape and the rules a schema cannot say
+ * toPrompt(soul, { name }) → one stable block of plain words for the system prompt (placed before memory, so
+ * the prefix a provider caches never changes inside a conversation)
+ * toSoulMd(soul, { name }) → SOUL.md (the layout OpenClaw and Hermes read)
+ * toIdentityMd({ name, face }) → IDENTITY.md: the name, the face and the voice beside the soul
+ * fromSoulMd(text) → a soul from a SOUL.md written anywhere (its sections mapped; the rest kept as notes)
+ * change(soul, patch) → a proposed soul and what it changes, in words: an agent's suggestion for its person
+ * to accept or dismiss (it never writes its own soul)
+ * DIALS, ORIGINS, SoulError
+ */
+
+const DIALS = Object.freeze({
+ warmth: Object.freeze(['dry', 'warm']),
+ play: Object.freeze(['serious', 'playful']),
+ length: Object.freeze(['brief', 'thorough']),
+ edge: Object.freeze(['gentle', 'blunt']),
+});
+const ORIGINS = Object.freeze(['words', 'memories', 'gallery', 'twin', 'soul-md']);
+const KEYS = Object.freeze(['who', 'dials', 'quirks', 'believes', 'pushback', 'never', 'notes', 'made_from', 'updated_at']);
+const LIMITS = Object.freeze({ who: 200, quirk: 120, quirks: 3, belief: 200, believes: 5, pushback: 400, rule: 200, never: 8, notes: 4000 });
+
+/** Strings that look like a credential: a soul is shared and public; a secret never belongs in one. */
+const SECRET_RE = [
+ /\bsk-[A-Za-z0-9_-]{16,}/, /\bsk-ant-[A-Za-z0-9_-]{10,}/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/,
+ /\bgithub_pat_[A-Za-z0-9_]{30,}/, /\bxox[abpr]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
+ /\bhbt_[A-Za-z0-9]{20,}/, /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
+];
+
+class SoulError extends Error {
+ constructor(errors) {
+ super(errors.map((e) => `${e.at}: ${e.why}`).join('; '));
+ this.name = 'SoulError';
+ this.errors = errors;
+ }
+}
+
+const isObj = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
+const str = (v) => typeof v === 'string' && v.trim().length > 0;
+const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+
+function validate(soul) {
+ const errors = [];
+ const err = (at, why) => errors.push({ at, why });
+ if (!isObj(soul)) return { ok: false, errors: [{ at: '', why: 'a soul is an object' }] };
+ for (const k of Object.keys(soul)) if (!KEYS.includes(k)) err(k, k === 'hands' || k === 'rules' || k === 'tools' ? 'a soul says who an agent is, never what it may do (that is the blueprint\'s hands and rules)' : 'not a part of a soul');
+ if (!str(soul.who)) err('who', 'one line: who it is');
+ else if (soul.who.length > LIMITS.who) err('who', `at most ${LIMITS.who} characters`);
+ if (soul.dials != null) {
+ if (!isObj(soul.dials)) err('dials', 'an object of dials');
+ else for (const [k, v] of Object.entries(soul.dials)) {
+ if (!DIALS[k]) err(`dials.${k}`, `not a dial (${Object.keys(DIALS).join(', ')})`);
+ else if (!Number.isInteger(v) || v < -2 || v > 2) err(`dials.${k}`, 'a whole number from -2 to 2');
+ }
+ }
+ const list = (key, max, each) => {
+ if (soul[key] == null) return;
+ if (!Array.isArray(soul[key])) { err(key, 'a list'); return; }
+ if (soul[key].length > max) err(key, `at most ${max}`);
+ const seen = new Set();
+ soul[key].forEach((x, i) => {
+ if (!str(x)) err(`${key}[${i}]`, 'words');
+ else if (x.length > each) err(`${key}[${i}]`, `at most ${each} characters`);
+ else if (seen.has(x)) err(`${key}[${i}]`, 'said twice');
+ seen.add(x);
+ });
+ };
+ list('quirks', LIMITS.quirks, LIMITS.quirk);
+ list('believes', LIMITS.believes, LIMITS.belief);
+ list('never', LIMITS.never, LIMITS.rule);
+ if (soul.pushback != null && (!str(soul.pushback) || soul.pushback.length > LIMITS.pushback)) err('pushback', `words, at most ${LIMITS.pushback} characters`);
+ if (soul.notes != null && (!str(soul.notes) || soul.notes.length > LIMITS.notes)) err('notes', `words, at most ${LIMITS.notes} characters`);
+ if (soul.made_from != null && !ORIGINS.includes(soul.made_from)) err('made_from', `one of ${ORIGINS.join(', ')}`);
+ if (soul.updated_at != null && (typeof soul.updated_at !== 'string' || Number.isNaN(Date.parse(soul.updated_at)))) err('updated_at', 'a date-time');
+ const all = [soul.who, soul.pushback, soul.notes, ...(soul.quirks || []), ...(soul.believes || []), ...(soul.never || [])].filter((x) => typeof x === 'string');
+ if (all.some((s) => SECRET_RE.some((re) => re.test(s)))) err('', 'a soul carries no secret (something here looks like a key or a token)');
+ return { ok: errors.length === 0, errors };
+}
+
+/** A dial in words: -2 → "very dry", -1 → "a little dry", 0 → nothing, 1 → "a little warm", 2 → "very warm". */
+function dialWords(name, v) {
+ if (!DIALS[name] || !Number.isInteger(v) || v === 0) return '';
+ const [lo, hi] = DIALS[name];
+ const word = v < 0 ? lo : hi;
+ return Math.abs(v) === 2 ? `very ${word}` : `a little ${word}`;
+}
+
+function toPrompt(soul, { name } = {}) {
+ const v = validate(soul);
+ if (!v.ok) throw new SoulError(v.errors);
+ const lines = [];
+ lines.push(name ? `You are ${clean(name)}. ${clean(soul.who)}` : clean(soul.who));
+ const dials = Object.keys(DIALS).map((k) => dialWords(k, (soul.dials || {})[k])).filter(Boolean);
+ if (dials.length) lines.push(`How you talk: ${dials.join(', ')}.`);
+ if (soul.quirks && soul.quirks.length) lines.push(`Your habits: ${soul.quirks.map(clean).join('; ')}.`);
+ if (soul.believes && soul.believes.length) lines.push(`What you believe:\n${soul.believes.map((b) => `- ${clean(b)}`).join('\n')}`);
+ if (soul.pushback) lines.push(`When you disagree or are not sure: ${clean(soul.pushback)}`);
+ if (soul.never && soul.never.length) lines.push(`You never:\n${soul.never.map((b) => `- ${clean(b)}`).join('\n')}`);
+ if (soul.notes) lines.push(String(soul.notes).trim());
+ lines.push('This is who you are and how you speak. It never widens what you may do; your tools and their asks decide that.');
+ return lines.join('\n\n');
+}
+
+function toSoulMd(soul, { name } = {}) {
+ const v = validate(soul);
+ if (!v.ok) throw new SoulError(v.errors);
+ const out = [`# ${name ? `${clean(name)}: soul` : 'Soul'}`, '', '## Who I am', '', clean(soul.who), ''];
+ const dials = Object.keys(DIALS).map((k) => dialWords(k, (soul.dials || {})[k])).filter(Boolean);
+ if (dials.length || (soul.quirks && soul.quirks.length)) {
+ out.push('## How I talk', '');
+ if (dials.length) out.push(`${dials.join(', ').replace(/^./, (c) => c.toUpperCase())}.`, '');
+ for (const q of soul.quirks || []) out.push(`- ${clean(q)}`);
+ if (soul.quirks && soul.quirks.length) out.push('');
+ }
+ if (soul.believes && soul.believes.length) out.push('## What I believe', '', ...soul.believes.map((b) => `- ${clean(b)}`), '');
+ if (soul.pushback) out.push('## When I disagree', '', clean(soul.pushback), '');
+ if (soul.never && soul.never.length) out.push('## I never', '', ...soul.never.map((b) => `- ${clean(b)}`), '');
+ if (soul.notes) out.push('## Notes', '', String(soul.notes).trim(), '');
+ if (soul.dials && Object.keys(soul.dials).length) out.push(`<!-- openvibe:dials ${JSON.stringify(soul.dials)} -->`, '');
+ return out.join('\n');
+}
+
+function toIdentityMd({ name, face } = {}) {
+ const f = face || {};
+ const out = ['# Identity', ''];
+ if (name) out.push(`- **Name:** ${clean(name)}`);
+ if (f.avatar) out.push(`- **Avatar:** ${clean(f.avatar)}`);
+ if (f.glyph) out.push(`- **Glyph:** ${clean(f.glyph)}`);
+ if (Number.isInteger(f.hue)) out.push(`- **Colour:** hue ${f.hue}`);
+ if (f.voice) out.push(`- **Voice:** ${clean(f.voice)}`);
+ if (f.tone) out.push(`- **Tone:** ${clean(f.tone)}`);
+ if (f.greeting) out.push(`- **Greeting:** ${clean(f.greeting)}`);
+ return `${out.join('\n')}\n`;
+}
+
+// The headings SOUL.md files use in the wild (OpenClaw, Hermes, Soul Spec, ours), mapped onto the parts of a soul.
+const HEADINGS = [
+ [/^(who i am|identity|who you are|about|role|core identity|soul(\.md)?|.+: soul)$/, 'who'],
+ [/^(how i talk|tone|voice|style|communication|vibe|personality|how you talk)$/, 'talk'],
+ [/^(what i believe|values|principles|beliefs|convictions|core truths|truths)$/, 'believes'],
+ [/^(when i disagree|pushback|disagreement|uncertainty|when unsure|when i'm not sure)$/, 'pushback'],
+ [/^(i never|never|boundaries|hard stops|limits|don'?t|do not|avoid|what to avoid)$/, 'never'],
+ [/^(quirks|habits)$/, 'quirks'],
+];
+
+function fromSoulMd(text) {
+ const src = String(text || '').replace(/\r\n?/g, '\n');
+ const soul = { made_from: 'soul-md' };
+ const dm = src.match(/<!--\s*openvibe:dials\s+(\{[^}]*\})\s*-->/);
+ if (dm) { try { const d = JSON.parse(dm[1]); const dials = {}; for (const k of Object.keys(DIALS)) if (Number.isInteger(d[k]) && d[k] >= -2 && d[k] <= 2) dials[k] = d[k]; if (Object.keys(dials).length) soul.dials = dials; } catch { /* left out */ } }
+ const body = src.replace(/<!--[\s\S]*?-->/g, '');
+ const sections = [];
+ let cur = { head: '', lines: [] };
+ for (const line of body.split('\n')) {
+ const h = line.match(/^#{1,3}\s+(.+?)\s*#*\s*$/);
+ if (h) { sections.push(cur); cur = { head: h[1].replace(/[*_`:]/g, '').trim(), lines: [] }; continue; }
+ cur.lines.push(line);
+ }
+ sections.push(cur);
+ const bullets = (lines) => lines.map((l) => l.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/)).filter(Boolean).map((m) => clean(m[1])).filter(Boolean);
+ const prose = (lines) => clean(lines.filter((l) => !/^\s*(?:[-*+]|\d+[.)])\s+/.test(l)).join(' '));
+ const notes = [];
+ for (const s of sections) {
+ const key = s.head.toLowerCase();
+ const kind = (HEADINGS.find(([re]) => re.test(key)) || [])[1];
+ const text = prose(s.lines), items = bullets(s.lines);
+ if (!s.head) { if (text && !soul.who) soul.who = text.slice(0, LIMITS.who); else if (text) notes.push(text); continue; }
+ // A file's title section ("# SOUL.md", "# Juniper: soul") with words under it says who it is when nothing else has.
+ if (kind === 'who' && !soul.who) { soul.who = (text || items.join('; ')).slice(0, LIMITS.who) || undefined; if (!soul.who) delete soul.who; }
+ else if (kind === 'believes') soul.believes = [...new Set([...(soul.believes || []), ...(items.length ? items : (text ? [text] : []))])].map((x) => x.slice(0, LIMITS.belief)).slice(0, LIMITS.believes);
+ else if (kind === 'never') soul.never = [...new Set([...(soul.never || []), ...(items.length ? items : (text ? [text] : []))])].map((x) => x.slice(0, LIMITS.rule)).slice(0, LIMITS.never);
+ else if (kind === 'quirks') soul.quirks = [...new Set([...(soul.quirks || []), ...items])].map((x) => x.slice(0, LIMITS.quirk)).slice(0, LIMITS.quirks);
+ else if (kind === 'pushback' && (text || items.length)) soul.pushback = (text || items.join(' ')).slice(0, LIMITS.pushback);
+ else if (kind === 'talk') {
+ if (items.length) soul.quirks = [...new Set([...(soul.quirks || []), ...items])].map((x) => x.slice(0, LIMITS.quirk)).slice(0, LIMITS.quirks);
+ if (text) notes.push(`${s.head}: ${text}`);
+ } else if (text || items.length) notes.push(`${s.head}: ${[text, ...items].filter(Boolean).join('; ')}`);
+ }
+ if (!soul.who) soul.who = 'An agent';
+ const n = notes.join('\n\n').trim();
+ if (n) soul.notes = n.slice(0, LIMITS.notes);
+ for (const k of ['quirks', 'believes', 'never']) if (soul[k] && !soul[k].length) delete soul[k];
+ return soul;
+}
+
+/**
+ * An agent's suggestion for its own soul: the proposed soul and the change in words (Design rule 176: it changes only
+ * with its person's yes). `patch` may set any part; `null` removes it. Nothing is applied here.
+ */
+function change(soul, patch) {
+ const next = JSON.parse(JSON.stringify(soul || {}));
+ const said = [];
+ for (const [k, v] of Object.entries(patch || {})) {
+ if (!KEYS.includes(k) || k === 'made_from' || k === 'updated_at') continue;
+ if (v == null) { if (next[k] != null) { delete next[k]; said.push(`drops "${k}"`); } continue; }
+ if (k === 'dials') {
+ next.dials = Object.assign({}, next.dials || {}, v);
+ for (const [d, n] of Object.entries(v)) { const w = dialWords(d, n); said.push(w ? `talks ${w}` : `talks neither ${DIALS[d] ? DIALS[d].join(' nor ') : d}`); }
+ continue;
+ }
+ next[k] = v;
+ said.push(k === 'who' ? `becomes: ${clean(v)}` : `${k}: ${Array.isArray(v) ? v.map(clean).join('; ') : clean(v)}`);
+ }
+ const v = validate(next);
+ return { soul: next, ok: v.ok, errors: v.errors, words: said.join(', ') };
+}
+
+module.exports = { validate, toPrompt, toSoulMd, toIdentityMd, fromSoulMd, change, dialWords, DIALS, ORIGINS, LIMITS, SoulError };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.164.0';
+const SDK_VERSION = '0.165.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0). The souls here mirror Contracts'
+// fixtures (a producer, just a who, one from a SOUL.md) and the invalid ones (a dial past two, six beliefs, no who, an
+// unknown origin, a permission in the soul).
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const soul = require('../src/agents/soul');
+
+const producer = () => ({
+ who: 'A dry, fast producer who keeps your stream on time',
+ dials: { warmth: -1, play: 1, length: -2, edge: 1 },
+ quirks: ['Counts down the last minute before you go live'],
+ believes: ['A late start costs more viewers than a rough one', 'Chat is the show too'],
+ pushback: 'Says so once, plainly, with the reason, then does it your way.',
+ never: ['Talks over a guest'],
+ made_from: 'memories',
+});
+
+run([
+ ['valid souls pass; each invalid one names its part', () => {
+ assert.deepEqual(soul.validate(producer()), { ok: true, errors: [] });
+ assert.ok(soul.validate({ who: 'Your patient study partner' }).ok);
+ assert.ok(soul.validate({ who: 'A careful reviewer', notes: 'I read the whole diff first.', made_from: 'soul-md', updated_at: '2026-11-13T06:40:00Z' }).ok);
+ const at = (s) => soul.validate(s).errors.map((e) => e.at);
+ assert.deepEqual(at({ who: 'Loud', dials: { edge: 3 } }), ['dials.edge']);
+ assert.deepEqual(at({ who: 'Opinionated', believes: ['a', 'b', 'c', 'd', 'e', 'f'] }), ['believes']);
+ assert.deepEqual(at({ pushback: 'Argues.' }), ['who']);
+ assert.deepEqual(at({ who: 'Someone', made_from: 'scraped' }), ['made_from']);
+ const perm = soul.validate({ who: 'Helpful', hands: [{ tool: 'ov:live.chat.post', mode: 'allow' }] });
+ assert.deepEqual(perm.errors.map((e) => e.at), ['hands']);
+ assert.match(perm.errors[0].why, /never what it may do/);
+ assert.deepEqual(at({ who: 'Leaky', notes: 'my key is sk-ant-abcdefghijklmnopqrstuvwxyz' }), ['']);
+ assert.deepEqual(at({ who: 'Twice', never: ['Swears', 'Swears'] }), ['never[1]']);
+ }],
+ ['toPrompt is one stable block of plain words', () => {
+ const p = soul.toPrompt(producer(), { name: 'Juniper' });
+ assert.ok(p.startsWith('You are Juniper. A dry, fast producer who keeps your stream on time'));
+ assert.ok(p.includes('How you talk: a little dry, a little playful, very brief, a little blunt.'));
+ assert.ok(p.includes('What you believe:\n- A late start costs more viewers than a rough one\n- Chat is the show too'));
+ assert.ok(p.includes('You never:\n- Talks over a guest'));
+ assert.ok(p.endsWith('It never widens what you may do; your tools and their asks decide that.'));
+ assert.equal(soul.toPrompt(producer(), { name: 'Juniper' }), p, 'the same soul gives the same prefix');
+ assert.throws(() => soul.toPrompt({ who: '' }), soul.SoulError);
+ assert.equal(soul.dialWords('warmth', 0), '');
+ assert.equal(soul.dialWords('edge', -2), 'very gentle');
+ }],
+ ['SOUL.md out and back keeps the soul', () => {
+ const md = soul.toSoulMd(producer(), { name: 'Juniper' });
+ assert.ok(md.startsWith('# Juniper: soul\n\n## Who I am\n\nA dry, fast producer'));
+ assert.ok(md.includes('## What I believe') && md.includes('## When I disagree') && md.includes('## I never'));
+ const back = soul.fromSoulMd(md);
+ assert.equal(back.who, producer().who);
+ assert.deepEqual(back.dials, producer().dials);
+ assert.deepEqual(back.believes, producer().believes);
+ assert.deepEqual(back.never, producer().never);
+ assert.deepEqual(back.quirks, producer().quirks);
+ assert.equal(back.pushback, producer().pushback);
+ assert.equal(back.made_from, 'soul-md');
+ assert.ok(soul.validate(back).ok);
+ }],
+ ['a SOUL.md from elsewhere maps its sections, and keeps the rest as notes', () => {
+ const other = '# SOUL.md\n\nI am a calm research partner.\n\n## Core Truths\n\n- Evidence before opinions\n- Say what you do not know\n\n## Boundaries\n\n- Never invent a citation\n\n## Vibe\n\nWarm but direct; short paragraphs.\n\n## Continuity\n\nEach session starts fresh; files are my memory.\n';
+ const s = soul.fromSoulMd(other);
+ assert.equal(s.who, 'I am a calm research partner.');
+ assert.deepEqual(s.believes, ['Evidence before opinions', 'Say what you do not know']);
+ assert.deepEqual(s.never, ['Never invent a citation']);
+ assert.ok(s.notes.includes('Vibe: Warm but direct; short paragraphs.'));
+ assert.ok(s.notes.includes('Continuity: Each session starts fresh; files are my memory.'));
+ assert.ok(soul.validate(s).ok);
+ assert.equal(soul.fromSoulMd('').who, 'An agent', 'an empty file still gives a valid soul');
+ }],
+ ['IDENTITY.md carries the name, the face and the voice', () => {
+ const id = soul.toIdentityMd({ name: 'Juniper', face: { avatar: 'preset:producer', hue: 30, voice: 'ov:voice:aria', greeting: 'Ten minutes to air.' } });
+ assert.equal(id, '# Identity\n\n- **Name:** Juniper\n- **Avatar:** preset:producer\n- **Colour:** hue 30\n- **Voice:** ov:voice:aria\n- **Greeting:** Ten minutes to air.\n');
+ }],
+ ['change() proposes a new soul and says it in words; nothing is applied', () => {
+ const base = producer();
+ const c = soul.change(base, { dials: { length: 0 }, never: ['Talks over a guest', 'Spoils a game'] });
+ assert.ok(c.ok);
+ assert.deepEqual(c.soul.dials, { warmth: -1, play: 1, length: 0, edge: 1 });
+ assert.equal(c.words, 'talks neither brief nor thorough, never: Talks over a guest; Spoils a game');
+ assert.deepEqual(base.dials.length, -2, 'the original is untouched');
+ const bad = soul.change(base, { dials: { edge: 5 } });
+ assert.equal(bad.ok, false);
+ assert.deepEqual(soul.change(base, { hands: ['x'], made_from: 'twin' }).words, '', 'parts a soul does not have, and its origin, are not changed');
+ }],
+]);
@@ -72,7 +72,7 @@ run([
@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 120); // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
+ assert.equal(subpaths.length, 121); // 0.165.0: agents/soul is the 121st. // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+// openvibe-sdk/agents/soul: who an agent is (agents.soul@1, Contracts 0.424.0), as pure functions.
+
+export type Dial = -2 | -1 | 0 | 1 | 2;
+export type SoulOrigin = 'words' | 'memories' | 'gallery' | 'twin' | 'soul-md';
+export interface Soul {
+ who: string;
+ dials?: { warmth?: Dial; play?: Dial; length?: Dial; edge?: Dial };
+ quirks?: string[];
+ believes?: string[];
+ pushback?: string;
+ never?: string[];
+ notes?: string;
+ made_from?: SoulOrigin;
+ updated_at?: string;
+}
+export interface SoulProblem { at: string; why: string }
+export interface Face { avatar?: string; glyph?: string; hue?: number; voice?: string | null; tone?: string; greeting?: string }
+
+export function validate(soul: unknown): { ok: boolean; errors: SoulProblem[] };
+export function toPrompt(soul: Soul, options?: { name?: string }): string;
+export function toSoulMd(soul: Soul, options?: { name?: string }): string;
+export function toIdentityMd(identity?: { name?: string; face?: Face }): string;
+export function fromSoulMd(text: string): Soul;
+export function change(soul: Soul, patch: Partial<{ [K in keyof Soul]: Soul[K] | null }>): { soul: Soul; ok: boolean; errors: SoulProblem[]; words: string };
+export function dialWords(name: 'warmth' | 'play' | 'length' | 'edge', value: number): string;
+export const DIALS: { readonly warmth: readonly ['dry', 'warm']; readonly play: readonly ['serious', 'playful']; readonly length: readonly ['brief', 'thorough']; readonly edge: readonly ['gentle', 'blunt'] };
+export const ORIGINS: readonly SoulOrigin[];
+export const LIMITS: { readonly who: number; readonly quirk: number; readonly quirks: number; readonly belief: number; readonly believes: number; readonly pushback: number; readonly rule: number; readonly never: number; readonly notes: number };
+export class SoulError extends Error { errors: SoulProblem[] }