openvibers/openvibe.sdk Commits

0.61.0: openvibe-sdk/no-looks (public AI text never describes a person: one filter and corpus for every service, moved from Live) (#73)

OP OpenVibers committed 22 days ago · 8600b94d3b · 13 files, +271 −10

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e9b4272..8354865 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,13 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.61.0 (2026-10-22)
++
++- **`openvibe-sdk/no-looks`**: the owner's rule that public AI text never describes a person, as one shared filter.
++ `noLooks(text)`, `noLooksTags(tags)`, `hasLooks(title)` and `RULES_VERSION`, moved from OpenVibe.Live
++ (Live#298–#302) with its test corpus, so Community, Search, MediaHub and every service that stores public AI text
++ run the same rules. Pure and dependency-free (Node and browsers).
++
## 0.60.0 (2026-10-20)
- **`openvibe-sdk/host`** and **`openvibe-sdk/news`**: the public reads of the last two products without a module.
ddiff --git a/README.md b/README.md
iindex 8d982a6..d69a56a 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.60.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.61.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.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 Contracts v0.86.0, a devDependency the tests check them against).
@@@ -198,6 +198,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/trade` | server | `createTradeClient(client)`: OpenVibe.Trade. `instruments.resolve(q)/list/get/observations(symbol, {metric, before, limit})/documents`, `sources()`; the person's `watchlists.list/get/create/rename/add/removeItem/remove` and `alerts.list/deliveries/create/remove`. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.trade` |
| `openvibe-sdk/space` | server | `createSpaceClient(client)`: OpenVibe.Space, static sites for apps and CI. `deploys.uploadArchive(site, tarGz, {activate, preview, root})`, `uploadFiles(site, [{path, data}], …)`, `uploadSourceBuild(site, archive, {ref, commit_sha})`, `list/get/log/activate/rollback(site, {deploy_id})/remove`; `projects.list/create/get/remove/quota/sites/members.set(p, principal, role)/remove`; `sites.create/get/remove`, `sites.config.get/set/reset` (`{ headers, redirects, spa }`), `sites.source.get/set/remove`; `domains.list/add/verify/remove`. An app reaches only the projects a person added it to (deployer, maintainer or owner). Public capabilities since openvibe-contracts 0.209.0. Audience `openvibe.space` |
| `openvibe-sdk/host` | server | `createHostClient(client)`: OpenVibe.Host's public hosting catalog, sent without a token. `providers.list/get(slug)`, `plans.list({kind, min_vcpu, min_ram_mb, min_disk_gb, max_price, currency, location, provider, sort, cursor})/get(id)`, `promos.list`, `offers.list({sort, currency, max_monthly, source, priced, cursor})`, `domains.list/tld('dev')/check('example.dev')`, `benchmarks.list/get`. Any other filter is refused (422). Editing the catalog is first-party |
++| `openvibe-sdk/no-looks` | any | Public AI text never describes a person: `noLooks(text)` removes looks, body, clothing, hair, face, pose, age and guessed gender ("a shirtless man sits" → "the streamer sits", "two men" → "two people"; quoted speech and names stay), `noLooksTags(tags)` drops tags that name them, `hasLooks(title)` says a title should be replaced, and `RULES_VERSION` gates a one-time repair of stored rows. Every service that stores or serves public AI text runs it |
| `openvibe-sdk/news` | server | `createNewsClient(client)`: OpenVibe.News' published stories, sent without a token. `topics()`, `stories.list({topic, limit, offset})` (published and retracted, newest first) and `stories.get(id)` (sources, timeline, perspectives). Writing and the editorial reads stay first-party |
| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |
| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 618dbf0..aa826e9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.60.0",
++ "version": "0.61.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",
@@@ -18,6 +18,7 @@
"community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
++ "no-looks (openvibe-sdk/no-looks): public AI text never describes a person, one filter and corpus for every service",
"the OpenVibe Frame for any app",
"mock platform for tests (openvibe-sdk/testing)",
"createTestDb: one migrated test database per process, loaded from a PGlite snapshot keyed by the migrations (OV_TEST_SNAPSHOT=0 turns it off)",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d3b6e71..a7fbe68 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.60.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.61.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,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.60.0';
++const SDK_VERSION = '0.61.0';
module.exports = {
createClient,
ddiff --git a/esm/no-looks.mjs b/esm/no-looks.mjs
nnew file mode 100644
iindex 0000000..22dd7be
---- /dev/null
++++ b/esm/no-looks.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/no-looks.js. Do not edit.
++import cjs from '../src/no-looks.js';
++
++export const {
++ noLooks, noLooksTags, hasLooks, RULES_VERSION,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex acf3eff..831a1eb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.60.0",
++ "version": "0.61.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.60.0",
++ "version": "0.61.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 94a7dc2..1208211 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.60.0",
++ "version": "0.61.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",
@@@ -362,6 +362,11 @@
"import": "./esm/news.mjs",
"default": "./src/news.js"
},
++ "./no-looks": {
++ "types": "./types/no-looks.d.ts",
++ "import": "./esm/no-looks.mjs",
++ "default": "./src/no-looks.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex aea20c0..2be5bc2 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -67,6 +67,7 @@ const ENTRIES = {
'space.mjs': '../src/space.js',
'host.mjs': '../src/host.js',
'news.mjs': '../src/news.js',
++ 'no-looks.mjs': '../src/no-looks.js',
'food.mjs': '../src/food.js',
'actor.mjs': '../src/actor.js',
'run.mjs': '../src/run.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 731577d..4b7d651 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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.60.0';
++const SDK_VERSION = '0.61.0';
module.exports = {
createClient,
ddiff --git a/src/no-looks.js b/src/no-looks.js
nnew file mode 100644
iindex 0000000..14203a2
---- /dev/null
++++ b/src/no-looks.js
@@@ -0,0 +1,134 @@
++'use strict';
++/**
++ * openvibe-sdk/no-looks: public AI text never describes a person (the owner's rule): no looks, body, clothing, hair,
++ * face, pose, age or guessed gender. Prompts say so, but models ignore it about one time in ten, so AI text that is
++ * stored or served publicly passes noLooks() (and noLooksTags() for tags), and a short AI title that names looks is
++ * not used (hasLooks()). Moved from OpenVibe.Live (server/db/database.js, Live#298–#302) so every service that stores
++ * public AI text runs the same rules against one test corpus (test/no-looks.test.js).
++ *
++ * - noLooks(text): "A shirtless man sits…" → "The streamer sits…"; "a man in a kitchen" → "a person in a kitchen";
++ * "two men" → "two people"; a clause about clothes, hair or a pose goes; a sentence about someone's hair, face,
++ * body or clothes that cannot be repaired word by word goes. Quoted speech ("I like your hair") stays word for word,
++ * and so do names (Iron Man, Pac-Man) and things a person holds or reviews (a headset on a desk).
++ * - noLooksTags(tags): drops tags that name clothing, hair, face, age or a person by gender ("red-beanie", "man").
++ * - hasLooks(text): true when a short text names looks at all; such a title is replaced rather than repaired.
++ * - RULES_VERSION: a short hash of the rules. A service that repairs stored rows records it and runs the repair again
++ * when it changes.
++ *
++ * Pure functions, no dependencies: safe in Node and in browsers.
++ */
++
++const _LOOKS = 'shirtless|topless|bare-chested|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
++const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
++const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
++const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
++const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
++const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
++// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",
++// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted
++// speech ("Heard: "I like your hair"") is what was said and stays word for word.
++const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
++const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
++const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
++const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
++const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
++const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
++const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
++const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
++const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
++const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
++// "…, while the other wears a dusty t-shirt;" goes as a whole clause.
++const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');
++// "a plaid/checkered shirt": a slashed pair of words before what is worn reads as one word, so the rules below see it.
++const _SLASHED_WORN = new RegExp(`\\b([\\w'-]+)/[\\w'-]+(?=\\s+(?:[\\w'-]+\\s+){0,2}?(?:${_WORN_ANY})\\b)`, 'gi');
++// ", with a blue jacket partially visible": a comma-led clause about clothes, whoever wears them, goes (clothes only:
++// "a desk with a headset" stays).
++const _WITH_CLOTHES = /,\s*with\s+(?:(?:a|an|the|their|his|her)\s+)?(?:[\w'-]+\s+){0,2}?(?:t-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|outfits?|dress(?:es)?|jerseys?|suits?|robes?|outerwear)\b[^,.;!?]*(?:,(?=\s+(?:is|are|was|were)\s))?/gi;
++function _noClothing(text) {
++ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
++ .replace(_SLASHED_WORN, '$1')
++ .replace(_WITH_CLOTHES, '')
++ // A parenthetical clause taken out of the middle ("A third person, with a red cap, is shown") leaves no ",,".
++ .replace(/\s*,\s*,\s*/g, ' ')
++ .replace(/\b[\w-]+-wearing\s+/gi, '')
++ .replace(_WEARS_AND, '')
++ .replace(_WEARS_CLAUSE, '')
++ .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')
++ .replace(_PERSON_WITH_WORN, '$1')
++ .replace(_WEARING, '')
++ .replace(_IN_WORN, '')
++ .replace(_WITH_FACE, '')
++ .replace(/\s+([,.;:!?])/g, '$1')
++ .replace(/,(?=[.;:!?])/g, '')
++ .replace(/([:;]),/g, '$1')
++ .replace(/ {2,}/g, ' '))).join('');
++}
++// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
++// clothing, hair, face, age or a person by gender is dropped.
++const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
++// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
++const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
++const _hasLooks = (t) => _LOOKS_WORD.test(String(t || ''));
++function _noLooksTags(tags) {
++ return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
++}
++// Body language is the person too ("reacting with a thoughtful pose", "in a relaxed posture"): the phrase goes.
++const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:reacting|sitting|standing|leaning|posing|looking|resting)\s+)?(?:with|in)\s+(?:an?|their|his|her)\s+(?:[\w-]+\s+){0,2}(?:pose|posture|expression|facial expression|stance)\b/gi;
++// A guessed gender is a description of the person too (owner's rule: no looks, body, clothing, hair, age or guessed
++// gender): "a man in a kitchen" is "a person in a kitchen", "two men" are "two people", "a male or female host" is "a
++// host". Only after an article or a count, so names (Iron Man, Pac-Man, Spice Girls) and quoted speech stay.
++const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
++const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
++const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
++const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
++// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
++// counter") cannot be repaired word by word: the sentence goes.
++const _GROOMING = new RegExp('\\b(?:(?:their|his|her)\\s+(?:own\\s+)?(?:hair|beard|face|makeup|clothing|clothes|outfit|appearance|body|figure|looks)'
++ // The face and body described ("smiling broadly, displaying white teeth and a beard") and clothes the word lists do
++ // not name ("blue outerwear", "in outdoor gear"): the sentence goes. Things a person holds or reviews (headphones, a
++ // headset on a desk) are not here.
++ + '|beards?|mustaches?|moustaches?|teeth|grin(?:s|ning)?|smil(?:e|es|ing)\\s+(?:broadly|widely|warmly|at)|freckles|complexion|wrinkles'
++ + '|hairstyles?|haircuts?|outerwear|(?:in|wearing)\\s+(?:[\\w-]+\\s+){0,2}gear|face\\s+shields?)\\b', 'i');
++function _noGender(text) {
++ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
++ .replace(_GENDER_ADJ, (m) => (/^an?\s/i.test(m) ? m.slice(0, m.indexOf(' ') + 1) : ''))
++ .replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
++ .replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
++ .replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
++ .replace(/ {2,}/g, ' '))).join('');
++}
++function _noGrooming(text) {
++ const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
++ return sentences.filter((s) => !_GROOMING.test(s.replace(/"[^"]*"?|“[^”]*”?/g, ''))).join('').trim();
++}
++function _noLooks(text) {
++ if (!text || typeof text !== 'string') return text;
++ return _noGrooming(_noGender(_noLooksWords(text)));
++}
++function _noLooksWords(text) {
++ return _noClothing(text).replace(_POSE, '').replace(/\s+([,.;:!?])/g, '$1').replace(/ {2,}/g, ' ')
++ // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
++ .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
++ .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\s+and\s+/gi, ' ')
++ .replace(/\b(?:shirtless|topless|bare-chested)\s+and\s+/gi, '')
++ .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')
++ .replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
++ .replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
++}
++
++// FNV-1a over the rules' source, twice with different offsets: a stable 16-hex-digit version.
++function _hash(text) {
++ let a = 0x811c9dc5, b = 0x01000193 ^ 0x5bd1e995;
++ for (let i = 0; i < text.length; i++) {
++ const c = text.charCodeAt(i);
++ a = Math.imul(a ^ c, 0x01000193) >>> 0;
++ b = Math.imul(b ^ c, 0x01000193 + 2) >>> 0;
++ }
++ return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
++}
++const RULES_VERSION = _hash([_LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
++ _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing].map(String).join('|'));
++
++module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION };
ddiff --git a/test/no-looks.test.js b/test/no-looks.test.js
nnew file mode 100644
iindex 0000000..e77a5c3
---- /dev/null
++++ b/test/no-looks.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++// openvibe-sdk/no-looks: the shared corpus. Every case here was public AI text on OpenVibe.Live or Community (frame
++// notes, overviews, clip lines, paste picture descriptions) or a near miss that must stay as written.
++const assert = require('assert');
++const { noLooks, noLooksTags, hasLooks, RULES_VERSION } = require('../src/no-looks');
++const esm = import('../esm/no-looks.mjs');
++
++const cases = [
++ ['A shirtless man sits in a room with framed pictures on the wall.', 'The streamer sits in a room with framed pictures on the wall.'],
++ ['A livestream frame showing a shirtless streamer in a small inset while playing a MOBA.', 'A livestream frame showing the streamer in a small inset while playing a MOBA.'],
++ ['Chat watches a young boy at a desk build a PC.', 'Chat watches the streamer at a desk build a PC.'],
++ ['Young, bearded man plays chess.', 'Streamer plays chess.'],
++ ['A man plays chess next to a black chair.', 'A person plays chess next to a black chair.'],
++ ['The streamer, topless, laughs at chat.', 'The streamer laughs at chat.'],
++ ['A man is shirtless and appears to be talking to chat.', 'A person appears to be talking to chat.'],
++ ['A person watches TV while a child sits nearby.', 'A person watches TV while someone sits nearby.'],
++ ['The girl waves at the camera.', 'The person waves at the camera.'],
++ // Clothing, hair and face go; the rest of the sentence stays.
++ ['The streamer wearing a red beanie speaks to the camera outdoors, seemingly addressing chat.', 'The streamer speaks to the camera outdoors, seemingly addressing chat.'],
++ ['The view is close to the camera while wearing a black beanie and hoodie, with trees around.', 'The view is close to the camera, with trees around.'],
++ ['A man in a gray tank top sits against a plain light background.', 'A person sits against a plain light background.'],
++ ['A man with a long beard and plaid shirt examines a woven item.', 'A person examines a woven item.'],
++ // Pose and expression are the person too (2026-10-17, the home page's "Right now").
++ ['A live stream frame shows the streamer in a small window bottom-left, appearing to be reacting with a thoughtful pose while the main screen is black.',
++ 'A live stream frame shows the streamer in a small window bottom-left while the main screen is black.'],
++ ['The streamer sits in a relaxed posture at the desk.', 'The streamer sits at the desk.'],
++ ['A kitchen studio with a headset-wearing host, microphone, and on-screen graphics.', 'A kitchen studio with a host, microphone, and on-screen graphics.'],
++ ['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],
++ ['A man wearing sunglasses on his head looks down at the camera.', 'A person looks down at the camera.'],
++ ['A collage of a man in a white shirt and tie wearing dark sunglasses in the left panel.', 'A collage of a person in the left panel.'],
++ ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind him a green mat; he is leaning to the left.'],
++ ['Two men lift weights, one shirtless and sweating.', 'Two people lift weights, one sweating.'],
++ ['Two men stand in an alley, one and drinking from a bottle while the other wears a dusty t-shirt; both look tired.', 'Two people stand in an alley, one drinking from a bottle; both look tired.'],
++ ['A man wearing a green bucket hat with frog-like eyes sits indoors.', 'A person sits indoors.'],
++ ['A man in sunglasses reaches toward the camera inside a car.', 'A person reaches toward the camera inside a car.'],
++ ['A live stream window shows a broadcaster with a headset on the left, a chat panel on the right.', 'A live stream window shows a broadcaster on the left, a chat panel on the right.'],
++ ['A man with a red shirt sits in a room; a desk with a headset and keyboard.', 'A person sits in a room; a desk with a headset and keyboard.'],
++ ['A gamer faces a casually dressed NPC in a grim interior.', 'A gamer faces a NPC in a grim interior.'],
++ ['A cartoon streaming setup: wearing a headset with a microphone, rugged jacket, backpack with a mat.', 'A cartoon streaming setup: backpack with a mat.'],
++ ['A partial view of a person wearing a gray zip-up jacket with a white zipper, seated indoors.', 'A partial view of a person, seated indoors.'],
++ ['Close-up of a person wearing a yellow vest over a gray knit top, with a backpack visible.', 'Close-up of a person, with a backpack visible.'],
++ ['A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.', 'A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.'],
++ ['The streamer laughs — heard: "Put your headset on. I like your hair."', 'The streamer laughs — heard: "Put your headset on. I like your hair."'],
++ // Clip overviews found public on 2026-10-22: a guessed gender, a slashed pattern before a shirt, a grooming sentence.
++ ['The video features a man in a plaid/checkered shirt delivering a live-streamed explanation or discussion.', 'The video features a person delivering a live-streamed explanation or discussion.'],
++ ['The video shows a man in a kitchen setting, appearing to stream live from a couch or kitchen area.', 'The video shows a person in a kitchen setting, appearing to stream live from a couch or kitchen area.'],
++ ['The video centers on a live stream featuring a male or female host wearing headphones, visible in a small webcam window.', 'The video centers on a live stream featuring a host, visible in a small webcam window.'],
++ ['A home kitchen where a person adjusts their hair or clothing near the counter. A pot of soup simmers on the stove.', 'A pot of soup simmers on the stove.'],
++ ['A female streamer and two guys race karts; the other men cheer.', 'A streamer and two people race karts; the other people cheer.'],
++ ['An Iron Man skin fights Pac-Man in a man-made arena while chat says "that man is cracked".', 'An Iron Man skin fights Pac-Man in a man-made arena while chat says "that man is cracked".'],
++ // The face, body and clothes the word lists do not name (production card lines after #299): the sentence goes.
++ ['A person in outdoor gear with a helmet and face shield is looking off to the side. A second person leans into the frame from the left.', 'A second person leans into the frame from the left.'],
++ ['The video features close-up shots of the streamer smiling broadly, displaying white teeth and a beard. The chat cheers.', 'The chat cheers.'],
++ ['The streamer packs camera gear into a bag.', 'The streamer packs camera gear into a bag.'],
++ ['A second person leans into the frame from the left, with a blue jacket partially visible.', 'A second person leans into the frame from the left.'],
++ ['A cozy desk, with a headset and a mug, faces the window.', 'A cozy desk, with a headset and a mug, faces the window.'],
++ ['The streamer, who is tired, is quiet tonight.', 'The streamer, who is tired, is quiet tonight.'],
++ ['A third person, with a blue jacket partially visible, is shown in a close-up, looking to the left and wearing an earbud.', 'A third person is shown in a close-up, looking to the left.'],
++];
++
++// Paste picture descriptions found public on Community (2026-10-22).
++cases.push(
++ ['A man lounging on a sofa in a living room, holding a remote and facing camera.', 'A person lounging on a sofa in a living room, holding a remote and facing camera.'],
++ ['A man is shown outdoors, smoking a cigarette against a clear blue sky.', 'A person is shown outdoors, smoking a cigarette against a clear blue sky.'],
++ ['A man sitting on a bed reading a book while homework papers and stacked boxes surround him.', 'A person sitting on a bed reading a book while homework papers and stacked boxes surround him.'],
++);
++
++let failed = 0;
++for (const [input, want] of cases) {
++ const got = noLooks(input);
++ if (got !== want) { failed++; console.error(`✗ ${JSON.stringify(input)}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`); }
++}
++assert.strictEqual(failed, 0, `${failed} of ${cases.length} cases`);
++
++// Not text: returned as given.
++assert.strictEqual(noLooks(null), null);
++assert.strictEqual(noLooks(''), '');
++assert.strictEqual(noLooks(undefined), undefined);
++
++// Tags: clothing, hair, face, age and gendered people go.
++assert.deepStrictEqual(noLooksTags(['red-beanie', 'bearded', 'man', 'tanktop', 'hula hoop', 'laptop', 'minecraft', 'outdoors', 'jacket', 'selfie']), ['hula hoop', 'laptop', 'minecraft', 'outdoors']);
++assert.deepStrictEqual(noLooksTags('man'), []);
++assert.deepStrictEqual(noLooksTags([1, '', ' ', 'sofa']), ['sofa']);
++
++// Titles: one that names looks is replaced, not repaired.
++assert.strictEqual(hasLooks('Orange Shirt Kitchen Chat'), true);
++assert.strictEqual(hasLooks('Minecraft Castle Marathon'), false);
++assert.strictEqual(hasLooks(null), false);
++
++// The rules' version: 16 hex digits, stable for these rules.
++assert.match(RULES_VERSION, /^[0-9a-f]{16}$/);
++
++esm.then((m) => {
++ assert.strictEqual(m.noLooks, noLooks);
++ assert.strictEqual(m.RULES_VERSION, RULES_VERSION);
++ console.log(`no-looks: ${cases.length} cases, tags, titles and the ESM entry pass (rules ${RULES_VERSION})`);
++}).catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex d5026fa..409f2da 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,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, 65);
++ assert.equal(subpaths.length, 66);
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 });
ddiff --git a/types/no-looks.d.ts b/types/no-looks.d.ts
nnew file mode 100644
iindex 0000000..744fb60
---- /dev/null
++++ b/types/no-looks.d.ts
@@@ -0,0 +1,8 @@
++/** Public AI text with every description of a person removed (looks, body, clothing, hair, face, pose, age, guessed gender). Non-strings are returned as given. */
++export function noLooks<T>(text: T): T;
++/** Tags that name no clothing, hair, face, age or gendered person. */
++export function noLooksTags(tags: unknown): string[];
++/** True when a short text (a title) names looks at all: replace it rather than repair it. */
++export function hasLooks(text: unknown): boolean;
++/** A 16-hex-digit hash of the rules: record it after repairing stored rows, and repair again when it changes. */
++export const RULES_VERSION: string;