openvibers/openvibe.sdk Commits
0.77.0: AI text may describe people; openvibe-sdk/explicit keeps the publishing gate (#105)
Changes
@@@ -1,5 +1,14 @@# Changelog++## 0.77.0 (2026-10-31)++++- **AI text may describe people** (the owner, 2026-10-31: "there should be no reason it can't describe people … remove++ all that crap"). `openvibe-sdk/no-looks` is deprecated. `noLooks`, `noLooksTags` and `hasLooks` return what they are++ given, and `RULES_VERSION` is `'off'`, so a service that still calls them keeps working until its next release++ removes the calls.++- **`openvibe-sdk/explicit`:** `isExplicit(...texts)`, the rule that stays. AI text that says a scene shows sexual++ content is not published. `no-looks` re-exports it.++## 0.76.0 (2026-10-30)- **`openvibe-sdk/placement`: placement v1 — quality, the person's rules, learning and a reason (agent plan A1).**@@@ -2,16 +2,16 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.76.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision 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.77.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision 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.**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.--After v0.76.0 is tagged:++After v0.77.0 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.77.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).@@@ -205,7 +205,8 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `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/redact` | any | The shared secret scrubber (Memory M2): `redact(text)` → `{ text, count }` removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (`password=[redacted]`); `onlySecret(text)` says an item was nothing but a secret. The same shapes and corpus as OpenVibe.Node's `internal/memory/redact.go`, so the Node and AI cannot drift |--| `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/explicit` | any | `isExplicit(...texts)`: true when AI text says a scene shows sexual content (body parts, sex acts, pornography, nudity); such a moment, clip or paste is not published |++| `openvibe-sdk/no-looks` | any | **Deprecated (2026-10-31):** AI text may describe people. `noLooks`, `noLooksTags` and `hasLooks` return what they are given; `isExplicit` is re-exported from `openvibe-sdk/explicit` || `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page || `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` |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.76.0",++ "version": "0.77.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -19,7 +19,7 @@"community, bot, search, bench, vision, 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",++ "explicit (openvibe-sdk/explicit): AI text that says a scene is sexual is not published; no-looks is deprecated (2026-10-31: AI text may describe people)","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)",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.76.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.77.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, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -46,7 +46,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.76.0';++const SDK_VERSION = '0.77.0';module.exports = {createClient,@@@ -2212,7 +2212,7 @@ function (module, exports) {* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal-- * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.++ * is the same whether or not the person exists.** The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from* /.well-known/oauth-protected-resource, as RFC 9728 says.@@@ -501,6 +501,9 @@"verifyDelivery": "function","verifyDeliveryV2": "function"},++ "openvibe-sdk/explicit": {++ "isExplicit": "function"++ },"openvibe-sdk/food": {"createFoodClient": "function"},nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/explicit.js. Do not edit.++import cjs from '../src/explicit.js';++++export const {++ isExplicit,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.76.0",++ "version": "0.77.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.76.0",++ "version": "0.77.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.76.0",++ "version": "0.77.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",@@@ -372,6 +372,11 @@"import": "./esm/news.mjs","default": "./src/news.js"},++ "./explicit": {++ "types": "./types/explicit.d.ts",++ "import": "./esm/explicit.mjs",++ "default": "./src/explicit.js"++ },"./no-looks": {"types": "./types/no-looks.d.ts","import": "./esm/no-looks.mjs",@@@ -69,6 +69,7 @@ const ENTRIES = {'space.mjs': '../src/space.js','host.mjs': '../src/host.js','news.mjs': '../src/news.js',++ 'explicit.mjs': '../src/explicit.js','no-looks.mjs': '../src/no-looks.js','redact.mjs': '../src/redact.js','vision.mjs': '../src/vision.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.76.0';++const SDK_VERSION = '0.77.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++/**++ * openvibe-sdk/explicit: AI text that says a scene shows sexual content (body parts, sex acts, pornography, nudity) is++ * not published at all — a moment, clip or paste whose description says so stays private. This is not about how++ * anyone looks (describing people is fine, owner 2026-10-31); it is about what a public page shows.++ *++ * isExplicit(title, overview) // from openvibe-sdk/explicit++ */++// What a scene shows: a service asks isExplicit() before it publishes AI text at all. Live's AI auto-clip 483 went public++// with such a description on 2026-10-23 (Live#344 had its own copy first).++const EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;++function isExplicit(...texts) {++ return texts.some((t) => t != null && EXPLICIT.test(String(t)));++}++++module.exports = { isExplicit };@@@ -1,170 +1,22 @@'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).++ * openvibe-sdk/no-looks: DEPRECATED (2026-10-31). The rule that public AI text never describes a person is gone: the++ * owner, "there should be no reason it can't describe people … remove all that crap." AI text may describe how people++ * look. These functions now return what they are given, so a service that still calls them keeps working until its++ * next release removes the calls; nothing new should call them.*-- * - 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.-- * - isExplicit(...texts): true when any text says a scene shows sexual content (body parts, sex acts, pornography,-- * nudity). That is not a looks rule and nothing repairs it: such a moment, clip or paste is not published at all.-- * - 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.++ * isExplicit (nothing sexual is published) is a different rule and stays: it lives in openvibe-sdk/explicit, and is++ * re-exported here for the services that import it from this module.*/++const { isExplicit } = require('./explicit');--const _LOOKS = 'shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed|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');--// Clothes below the waist say what someone wears as much ("The streamer in shorts kneels on grass…", a Community--// summary from 2026-04 that every repair left), but "in the pants department" is a place: a shop word after it keeps it.--const _LOWER = 'shorts|jeans|pants|trousers|leggings|skirts?|sweatpants|joggers|pajamas|pyjamas|swimsuits?|swimwear|bikinis?|underwear|lingerie|overalls|tights|dungarees';--const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN}|(?:${_LOWER})(?!\\s+(?:department|store|shop|section|aisle|pockets?|drawer)))\\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');--// Names are not people (as noLooks spares them): "Iron Man Speedrun", "Pac-Man Marathon", "Spider-Man 2" are titles--// about a game or a character, so a hyphenated name and the few two-word ones are skipped before the word test.--const _NAMED = /\b(?:iron|mega|pac|ant|spider|bat|he|ray)[\s-]+(?:man|men|woman|women)\b|-(?:man|men|woman|women|boy|girl)\b/gi;--const _hasLooks = (t) => _LOOKS_WORD.test(String(t || '').replace(_NAMED, ' '));--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');--// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her--// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside--// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person--// looks"), which "they" would not.--const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;--const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;--const _PRONOUNS = [-- [/\b(?:himself|herself)\b/gi, 'themself'],-- [/\bhers\b/gi, 'theirs'],-- [/\bhim\b/gi, 'them'],-- [_HER_OBJECT, 'them'],-- [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],-- [/\bhis\b/gi, 'theirs'],--];--const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };--function _noPronouns(part) {-- let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));-- for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));-- return out;--}--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, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).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|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, ' ')-- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\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|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b/gi, ' is on camera')-- .replace(/\s*,\s*(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s*,/gi, '')-- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\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');--}--// What a scene shows, not how someone looks: the looks rules take out an adjective ("a naked man … reaching toward--// their genitals" → "a man … reaching toward their genitals"), so a service asks isExplicit() before it publishes AI text--// at all. Live's AI auto-clip 483 went public with such a description on 2026-10-23 (Live#344 had its own copy first).--const _EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;--function _isExplicit(...texts) {-- return texts.some((t) => t != null && _EXPLICIT.test(String(t)));--}----const RULES_VERSION = _hash([_EXPLICIT, _NAMED, _LOOKS, _PEOPLE, _WORN, _LOWER, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,-- _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));++/** @deprecated The text as given. */++const noLooks = (text) => text;++/** @deprecated The tags as given (strings only, as before). */++const noLooksTags = (tags) => (Array.isArray(tags) ? tags.filter((t) => typeof t === 'string') : []);++/** @deprecated Always false: a title that describes people is fine. */++const hasLooks = () => false;++/** @deprecated The rules are off: a service that repairs stored rows when this changes runs its repair once more, as a no-op. */++const RULES_VERSION = 'off';--module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, isExplicit: _isExplicit, RULES_VERSION };++module.exports = { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION };@@@ -14,7 +14,7 @@* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal-- * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.++ * is the same whether or not the person exists.** The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from* /.well-known/oauth-protected-resource, as RFC 9728 says.@@@ -1,137 +1,35 @@'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.++// openvibe-sdk/no-looks is deprecated (owner, 2026-10-31: AI text may describe people): its functions return what they++// are given. openvibe-sdk/explicit keeps the one rule that stays: AI text that says a scene shows sexual content is not++// published.const assert = require('assert');const { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION } = require('../src/no-looks');--const esm = import('../esm/no-looks.mjs');++const explicit = require('../src/explicit');--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.'],-- // 2026-10-24: the AI moments job titled clip 483 "A Naked Man Lying On Their Back With Legs" on Live's home.-- ['A naked man lies on his back on a bed.', 'The streamer lies on their back on a bed.'],-- ['The streamer, half-naked, waves at chat.', 'The streamer waves at chat.'],-- ['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 them a green mat; the person 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.'],-- // Clothes below the waist (a Community screenshot summary of 2026-04); a shop or a pocket is not something worn.-- ['The streamer in shorts kneels on grass beside a small black dog in an outdoor setting.', 'The streamer kneels on grass beside a small black dog in an outdoor setting.'],-- ['The host in grey sweatpants and a cap laughs.', 'The host laughs.'],-- ['Someone in the pants department shops.', 'Someone in the pants department shops.'],-- ['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 them.'],--);----// Pronouns are a guessed gender too (27 repaired Community summaries kept "he looks", "on his device"): he and she are--// "the person", his and her "their", him and her "them"; quoted speech stays.--cases.push(-- ['He looks at the camera and adjusts the volume on his device.', 'The person looks at the camera and adjusts the volume on their device.'],-- ['The streamer checks his phone and laughs at her joke.', 'The streamer checks their phone and laughs at their joke.'],-- ['Chat cheers for her.', 'Chat cheers for them.'],-- ['The streamer gives her a wave and thanks him for the raid.', 'The streamer gives them a wave and thanks them for the raid.'],-- ["She's playing Minecraft with him; he'll join later.", "The person's playing Minecraft with them; the person will join later."],-- ['He hurt himself, but the win is his.', 'The person hurt themself, but the win is theirs.'],-- ['A person reads, says "he is lying to her", and waves at her chat.', 'A person reads, says "he is lying to her", and waves at their chat.'],-- ['Shell, Heather and the theme park stay as written.', 'Shell, Heather and the theme park stay as written.'],--);----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)}`); }++// Describing people is fine: the text, the tags and the titles come back as given.++for (const t of ['A shirtless man sits in a room with framed pictures on the wall.', 'She has a hat and glasses on.', 'Young, bearded man plays chess.', '', null, 42]) {++ assert.strictEqual(noLooks(t), t, `as given: ${t}`);}--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('A Naked Man Lying On Their Back With Legs'), true);--assert.strictEqual(hasLooks('Noteworthy Half-Naked Moment'), true, 'no person word: the nudity word alone');--assert.strictEqual(hasLooks('Nude Beach Debate'), true);--assert.strictEqual(hasLooks('Iron Man Speedrun Record'), false, 'a name is not a person');--assert.strictEqual(hasLooks('Pac-Man Marathon'), false);--assert.strictEqual(hasLooks('Spider-Man 2 Swing Practice'), false);--assert.strictEqual(hasLooks('Man Builds A Castle'), true);--assert.strictEqual(hasLooks(null), false);----// The rules' version: 16 hex digits, stable for these rules.--assert.match(RULES_VERSION, /^[0-9a-f]{16}$/);++assert.deepStrictEqual(noLooksTags(['red-beanie', 'man', 'minecraft', 7]), ['red-beanie', 'man', 'minecraft'], 'string tags as given');++assert.deepStrictEqual(noLooksTags(null), []);++assert.strictEqual(hasLooks('A Shirtless DJ Spins'), false, 'a title that describes someone is fine');++assert.strictEqual(RULES_VERSION, 'off');--// isExplicit: what a scene shows (Live's clip 483 on 2026-10-23, word for word, and after the adjective repair).++// isExplicit: what a scene shows (Live's clip 483 on 2026-10-23, word for word), from either module.++assert.strictEqual(isExplicit, explicit.isExplicit, 'one function, re-exported');for (const t of ['A naked man lying on their back with legs spread, reaching toward their genitals',-- noLooks('A naked man lying on their back with legs spread, reaching toward their genitals'),'The video shows the streamer engaging in explicit adult content, with close-up footage of sexual acts on a couch','Pornographic footage plays on the second monitor', 'The scene contains nudity', 'masturbating on camera',]) assert.ok(isExplicit(t), `explicit: ${t}`);--for (const t of ['A player in Fortnite is sprinting along a pink-purple sunset map', 'They read the news from Essex', 'A sextant and a map', '', null, undefined]) {++for (const t of ['A player in Fortnite is sprinting along a pink-purple sunset map', 'They read the news from Essex', 'A sextant and a map', 'A shirtless man sits', '', null, undefined]) {assert.ok(!isExplicit(t), `not explicit: ${t}`);}assert.ok(isExplicit('a fine title', null, 'the frame shows sexual activity'), 'any of several texts');--esm.then((m) => {++Promise.all([import('../esm/no-looks.mjs'), import('../esm/explicit.mjs')]).then(([m, e]) => {assert.strictEqual(m.noLooks, noLooks);assert.strictEqual(m.isExplicit, isExplicit);-- assert.strictEqual(m.RULES_VERSION, RULES_VERSION);-- console.log(`no-looks: ${cases.length} cases, tags, titles and the ESM entry pass (rules ${RULES_VERSION})`);++ assert.strictEqual(e.isExplicit, isExplicit);++ console.log('no-looks: deprecated, returns what it is given; explicit: the publishing gate holds (CJS and ESM)');}).catch((e) => { console.error(e); process.exit(1); });@@@ -68,7 +68,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, 71);++ assert.equal(subpaths.length, 72); // 0.77.0 adds openvibe-sdk/explicitassert.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,2 @@++/** True when any of the texts says a scene shows sexual content (body parts, sex acts, pornography, nudity): publish none of it. */++export function isExplicit(...texts: unknown[]): boolean;@@@ -1,10 +1,10 @@--/** 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. */++/** @deprecated (2026-10-31) AI text may describe people. Returns the text as given. */export function noLooks<T>(text: T): T;--/** Tags that name no clothing, hair, face, age or gendered person. */++/** @deprecated Returns the string tags as given. */export function noLooksTags(tags: unknown): string[];--/** True when a short text (a title) names looks at all: replace it rather than repair it. */++/** @deprecated Always false. */export function hasLooks(text: unknown): boolean;--/** True when any of the texts says a scene shows sexual content (body parts, sex acts, pornography, nudity): publish none of it. */++/** True when any of the texts says a scene shows sexual content: publish none of it. Prefer openvibe-sdk/explicit. */export function isExplicit(...texts: unknown[]): boolean;--/** A 16-hex-digit hash of the rules: record it after repairing stored rows, and repair again when it changes. */++/** @deprecated 'off'. */export const RULES_VERSION: string;
@@ -1,5 +1,14 @@
@@ -1,5 +1,14 @@
# Changelog
# Changelog
+## 0.77.0 (2026-10-31)
+
+- **AI text may describe people** (the owner, 2026-10-31: "there should be no reason it can't describe people … remove
+ all that crap"). `openvibe-sdk/no-looks` is deprecated. `noLooks`, `noLooksTags` and `hasLooks` return what they are
+ given, and `RULES_VERSION` is `'off'`, so a service that still calls them keeps working until its next release
+ removes the calls.
+- **`openvibe-sdk/explicit`:** `isExplicit(...texts)`, the rule that stays. AI text that says a scene shows sexual
+ content is not published. `no-looks` re-exports it.
+
## 0.76.0 (2026-10-30)
## 0.76.0 (2026-10-30)
- **`openvibe-sdk/placement`: placement v1 — quality, the person's rules, learning and a reason (agent plan A1).**
- **`openvibe-sdk/placement`: placement v1 — quality, the person's rules, learning and a reason (agent plan A1).**
@@ -2,16 +2,16 @@
@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.76.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision 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.77.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision 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.
**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.
**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.
-After v0.76.0 is tagged:
+After v0.77.0 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.77.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).
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).
@@ -205,7 +205,8 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -205,7 +205,8 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `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/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/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/redact` | any | The shared secret scrubber (Memory M2): `redact(text)` → `{ text, count }` removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (`password=[redacted]`); `onlySecret(text)` says an item was nothing but a secret. The same shapes and corpus as OpenVibe.Node's `internal/memory/redact.go`, so the Node and AI cannot drift |
| `openvibe-sdk/redact` | any | The shared secret scrubber (Memory M2): `redact(text)` → `{ text, count }` removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (`password=[redacted]`); `onlySecret(text)` says an item was nothing but a secret. The same shapes and corpus as OpenVibe.Node's `internal/memory/redact.go`, so the Node and AI cannot drift |
-| `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/explicit` | any | `isExplicit(...texts)`: true when AI text says a scene shows sexual content (body parts, sex acts, pornography, nudity); such a moment, clip or paste is not published |
+| `openvibe-sdk/no-looks` | any | **Deprecated (2026-10-31):** AI text may describe people. `noLooks`, `noLooksTags` and `hasLooks` return what they are given; `isExplicit` is re-exported from `openvibe-sdk/explicit` |
| `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page |
| `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page |
| `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/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/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` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.76.0",
+ "version": "0.77.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"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",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"community, bot, search, bench, vision, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients",
"community, bot, search, bench, vision, 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",
"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)",
"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",
+ "explicit (openvibe-sdk/explicit): AI text that says a scene is sexual is not published; no-looks is deprecated (2026-10-31: AI text may describe people)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
"mock platform for tests (openvibe-sdk/testing)",
"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)",
"createTestDb: one migrated test database per process, loaded from a PGlite snapshot keyed by the migrations (OV_TEST_SNAPSHOT=0 turns it off)",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.76.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.77.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, registry, modules, realtime, media, community, bot, search, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -46,7 +46,7 @@ const { paginate, offsetPager } = __load(7);
@@ -46,7 +46,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.76.0';
+const SDK_VERSION = '0.77.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -2212,7 +2212,7 @@ function (module, exports) {
@@ -2212,7 +2212,7 @@ function (module, exports) {
* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
- * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.
+ * is the same whether or not the person exists.
*
*
* The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
* The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
* /.well-known/oauth-protected-resource, as RFC 9728 says.
* /.well-known/oauth-protected-resource, as RFC 9728 says.
@@ -501,6 +501,9 @@
@@ -501,6 +501,9 @@
"verifyDelivery": "function",
"verifyDelivery": "function",
"verifyDeliveryV2": "function"
"verifyDeliveryV2": "function"
},
},
+ "openvibe-sdk/explicit": {
+ "isExplicit": "function"
+ },
"openvibe-sdk/food": {
"openvibe-sdk/food": {
"createFoodClient": "function"
"createFoodClient": "function"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/explicit.js. Do not edit.
+import cjs from '../src/explicit.js';
+
+export const {
+ isExplicit,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.76.0",
+ "version": "0.77.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.76.0",
+ "version": "0.77.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.76.0",
+ "version": "0.77.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",
@@ -372,6 +372,11 @@
@@ -372,6 +372,11 @@
"import": "./esm/news.mjs",
"import": "./esm/news.mjs",
"default": "./src/news.js"
"default": "./src/news.js"
},
},
+ "./explicit": {
+ "types": "./types/explicit.d.ts",
+ "import": "./esm/explicit.mjs",
+ "default": "./src/explicit.js"
+ },
"./no-looks": {
"./no-looks": {
"types": "./types/no-looks.d.ts",
"types": "./types/no-looks.d.ts",
"import": "./esm/no-looks.mjs",
"import": "./esm/no-looks.mjs",
@@ -69,6 +69,7 @@ const ENTRIES = {
@@ -69,6 +69,7 @@ const ENTRIES = {
'space.mjs': '../src/space.js',
'space.mjs': '../src/space.js',
'host.mjs': '../src/host.js',
'host.mjs': '../src/host.js',
'news.mjs': '../src/news.js',
'news.mjs': '../src/news.js',
+ 'explicit.mjs': '../src/explicit.js',
'no-looks.mjs': '../src/no-looks.js',
'no-looks.mjs': '../src/no-looks.js',
'redact.mjs': '../src/redact.js',
'redact.mjs': '../src/redact.js',
'vision.mjs': '../src/vision.js',
'vision.mjs': '../src/vision.js',
@@ -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.76.0';
+const SDK_VERSION = '0.77.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+/**
+ * openvibe-sdk/explicit: AI text that says a scene shows sexual content (body parts, sex acts, pornography, nudity) is
+ * not published at all — a moment, clip or paste whose description says so stays private. This is not about how
+ * anyone looks (describing people is fine, owner 2026-10-31); it is about what a public page shows.
+ *
+ * isExplicit(title, overview) // from openvibe-sdk/explicit
+ */
+// What a scene shows: a service asks isExplicit() before it publishes AI text at all. Live's AI auto-clip 483 went public
+// with such a description on 2026-10-23 (Live#344 had its own copy first).
+const EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
+function isExplicit(...texts) {
+ return texts.some((t) => t != null && EXPLICIT.test(String(t)));
+}
+
+module.exports = { isExplicit };
@@ -1,170 +1,22 @@
@@ -1,170 +1,22 @@
'use strict';
'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).
+ * openvibe-sdk/no-looks: DEPRECATED (2026-10-31). The rule that public AI text never describes a person is gone: the
+ * owner, "there should be no reason it can't describe people … remove all that crap." AI text may describe how people
+ * look. These functions now return what they are given, so a service that still calls them keeps working until its
+ * next release removes the calls; nothing new should call them.
*
*
- * - 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.
- * - isExplicit(...texts): true when any text says a scene shows sexual content (body parts, sex acts, pornography,
- * nudity). That is not a looks rule and nothing repairs it: such a moment, clip or paste is not published at all.
- * - 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.
+ * isExplicit (nothing sexual is published) is a different rule and stays: it lives in openvibe-sdk/explicit, and is
+ * re-exported here for the services that import it from this module.
*/
*/
+const { isExplicit } = require('./explicit');
-const _LOOKS = 'shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed|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');
-// Clothes below the waist say what someone wears as much ("The streamer in shorts kneels on grass…", a Community
-// summary from 2026-04 that every repair left), but "in the pants department" is a place: a shop word after it keeps it.
-const _LOWER = 'shorts|jeans|pants|trousers|leggings|skirts?|sweatpants|joggers|pajamas|pyjamas|swimsuits?|swimwear|bikinis?|underwear|lingerie|overalls|tights|dungarees';
-const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN}|(?:${_LOWER})(?!\\s+(?:department|store|shop|section|aisle|pockets?|drawer)))\\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');
-// Names are not people (as noLooks spares them): "Iron Man Speedrun", "Pac-Man Marathon", "Spider-Man 2" are titles
-// about a game or a character, so a hyphenated name and the few two-word ones are skipped before the word test.
-const _NAMED = /\b(?:iron|mega|pac|ant|spider|bat|he|ray)[\s-]+(?:man|men|woman|women)\b|-(?:man|men|woman|women|boy|girl)\b/gi;
-const _hasLooks = (t) => _LOOKS_WORD.test(String(t || '').replace(_NAMED, ' '));
-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');
-// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her
-// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside
-// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person
-// looks"), which "they" would not.
-const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;
-const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;
-const _PRONOUNS = [
- [/\b(?:himself|herself)\b/gi, 'themself'],
- [/\bhers\b/gi, 'theirs'],
- [/\bhim\b/gi, 'them'],
- [_HER_OBJECT, 'them'],
- [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],
- [/\bhis\b/gi, 'theirs'],
-];
-const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };
-function _noPronouns(part) {
- let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));
- for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));
- return out;
-}
-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, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).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|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, ' ')
- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\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|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b/gi, ' is on camera')
- .replace(/\s*,\s*(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s*,/gi, '')
- .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\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');
-}
-// What a scene shows, not how someone looks: the looks rules take out an adjective ("a naked man … reaching toward
-// their genitals" → "a man … reaching toward their genitals"), so a service asks isExplicit() before it publishes AI text
-// at all. Live's AI auto-clip 483 went public with such a description on 2026-10-23 (Live#344 had its own copy first).
-const _EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
-function _isExplicit(...texts) {
- return texts.some((t) => t != null && _EXPLICIT.test(String(t)));
-}
-
-const RULES_VERSION = _hash([_EXPLICIT, _NAMED, _LOOKS, _PEOPLE, _WORN, _LOWER, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
- _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));
+/** @deprecated The text as given. */
+const noLooks = (text) => text;
+/** @deprecated The tags as given (strings only, as before). */
+const noLooksTags = (tags) => (Array.isArray(tags) ? tags.filter((t) => typeof t === 'string') : []);
+/** @deprecated Always false: a title that describes people is fine. */
+const hasLooks = () => false;
+/** @deprecated The rules are off: a service that repairs stored rows when this changes runs its repair once more, as a no-op. */
+const RULES_VERSION = 'off';
-module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, isExplicit: _isExplicit, RULES_VERSION };
+module.exports = { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION };
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
* capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
* vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
* vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
- * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.
+ * is the same whether or not the person exists.
*
*
* The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
* The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
* /.well-known/oauth-protected-resource, as RFC 9728 says.
* /.well-known/oauth-protected-resource, as RFC 9728 says.
@@ -1,137 +1,35 @@
@@ -1,137 +1,35 @@
'use strict';
'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.
+// openvibe-sdk/no-looks is deprecated (owner, 2026-10-31: AI text may describe people): its functions return what they
+// are given. openvibe-sdk/explicit keeps the one rule that stays: AI text that says a scene shows sexual content is not
+// published.
const assert = require('assert');
const assert = require('assert');
const { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION } = require('../src/no-looks');
const { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION } = require('../src/no-looks');
-const esm = import('../esm/no-looks.mjs');
+const explicit = require('../src/explicit');
-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.'],
- // 2026-10-24: the AI moments job titled clip 483 "A Naked Man Lying On Their Back With Legs" on Live's home.
- ['A naked man lies on his back on a bed.', 'The streamer lies on their back on a bed.'],
- ['The streamer, half-naked, waves at chat.', 'The streamer waves at chat.'],
- ['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 them a green mat; the person 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.'],
- // Clothes below the waist (a Community screenshot summary of 2026-04); a shop or a pocket is not something worn.
- ['The streamer in shorts kneels on grass beside a small black dog in an outdoor setting.', 'The streamer kneels on grass beside a small black dog in an outdoor setting.'],
- ['The host in grey sweatpants and a cap laughs.', 'The host laughs.'],
- ['Someone in the pants department shops.', 'Someone in the pants department shops.'],
- ['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 them.'],
-);
-
-// Pronouns are a guessed gender too (27 repaired Community summaries kept "he looks", "on his device"): he and she are
-// "the person", his and her "their", him and her "them"; quoted speech stays.
-cases.push(
- ['He looks at the camera and adjusts the volume on his device.', 'The person looks at the camera and adjusts the volume on their device.'],
- ['The streamer checks his phone and laughs at her joke.', 'The streamer checks their phone and laughs at their joke.'],
- ['Chat cheers for her.', 'Chat cheers for them.'],
- ['The streamer gives her a wave and thanks him for the raid.', 'The streamer gives them a wave and thanks them for the raid.'],
- ["She's playing Minecraft with him; he'll join later.", "The person's playing Minecraft with them; the person will join later."],
- ['He hurt himself, but the win is his.', 'The person hurt themself, but the win is theirs.'],
- ['A person reads, says "he is lying to her", and waves at her chat.', 'A person reads, says "he is lying to her", and waves at their chat.'],
- ['Shell, Heather and the theme park stay as written.', 'Shell, Heather and the theme park stay as written.'],
-);
-
-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)}`); }
+// Describing people is fine: the text, the tags and the titles come back as given.
+for (const t of ['A shirtless man sits in a room with framed pictures on the wall.', 'She has a hat and glasses on.', 'Young, bearded man plays chess.', '', null, 42]) {
+ assert.strictEqual(noLooks(t), t, `as given: ${t}`);
}
}
-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('A Naked Man Lying On Their Back With Legs'), true);
-assert.strictEqual(hasLooks('Noteworthy Half-Naked Moment'), true, 'no person word: the nudity word alone');
-assert.strictEqual(hasLooks('Nude Beach Debate'), true);
-assert.strictEqual(hasLooks('Iron Man Speedrun Record'), false, 'a name is not a person');
-assert.strictEqual(hasLooks('Pac-Man Marathon'), false);
-assert.strictEqual(hasLooks('Spider-Man 2 Swing Practice'), false);
-assert.strictEqual(hasLooks('Man Builds A Castle'), true);
-assert.strictEqual(hasLooks(null), false);
-
-// The rules' version: 16 hex digits, stable for these rules.
-assert.match(RULES_VERSION, /^[0-9a-f]{16}$/);
+assert.deepStrictEqual(noLooksTags(['red-beanie', 'man', 'minecraft', 7]), ['red-beanie', 'man', 'minecraft'], 'string tags as given');
+assert.deepStrictEqual(noLooksTags(null), []);
+assert.strictEqual(hasLooks('A Shirtless DJ Spins'), false, 'a title that describes someone is fine');
+assert.strictEqual(RULES_VERSION, 'off');
-// isExplicit: what a scene shows (Live's clip 483 on 2026-10-23, word for word, and after the adjective repair).
+// isExplicit: what a scene shows (Live's clip 483 on 2026-10-23, word for word), from either module.
+assert.strictEqual(isExplicit, explicit.isExplicit, 'one function, re-exported');
for (const t of [
for (const t of [
'A naked man lying on their back with legs spread, reaching toward their genitals',
'A naked man lying on their back with legs spread, reaching toward their genitals',
- noLooks('A naked man lying on their back with legs spread, reaching toward their genitals'),
'The video shows the streamer engaging in explicit adult content, with close-up footage of sexual acts on a couch',
'The video shows the streamer engaging in explicit adult content, with close-up footage of sexual acts on a couch',
'Pornographic footage plays on the second monitor', 'The scene contains nudity', 'masturbating on camera',
'Pornographic footage plays on the second monitor', 'The scene contains nudity', 'masturbating on camera',
]) assert.ok(isExplicit(t), `explicit: ${t}`);
]) assert.ok(isExplicit(t), `explicit: ${t}`);
-for (const t of ['A player in Fortnite is sprinting along a pink-purple sunset map', 'They read the news from Essex', 'A sextant and a map', '', null, undefined]) {
+for (const t of ['A player in Fortnite is sprinting along a pink-purple sunset map', 'They read the news from Essex', 'A sextant and a map', 'A shirtless man sits', '', null, undefined]) {
assert.ok(!isExplicit(t), `not explicit: ${t}`);
assert.ok(!isExplicit(t), `not explicit: ${t}`);
}
}
assert.ok(isExplicit('a fine title', null, 'the frame shows sexual activity'), 'any of several texts');
assert.ok(isExplicit('a fine title', null, 'the frame shows sexual activity'), 'any of several texts');
-esm.then((m) => {
+Promise.all([import('../esm/no-looks.mjs'), import('../esm/explicit.mjs')]).then(([m, e]) => {
assert.strictEqual(m.noLooks, noLooks);
assert.strictEqual(m.noLooks, noLooks);
assert.strictEqual(m.isExplicit, isExplicit);
assert.strictEqual(m.isExplicit, isExplicit);
- assert.strictEqual(m.RULES_VERSION, RULES_VERSION);
- console.log(`no-looks: ${cases.length} cases, tags, titles and the ESM entry pass (rules ${RULES_VERSION})`);
+ assert.strictEqual(e.isExplicit, isExplicit);
+ console.log('no-looks: deprecated, returns what it is given; explicit: the publishing gate holds (CJS and ESM)');
}).catch((e) => { console.error(e); process.exit(1); });
}).catch((e) => { console.error(e); process.exit(1); });
@@ -68,7 +68,7 @@ run([
@@ -68,7 +68,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, 71);
+ assert.equal(subpaths.length, 72); // 0.77.0 adds openvibe-sdk/explicit
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,2 @@
@@ -0,0 +1,2 @@
+/** True when any of the texts says a scene shows sexual content (body parts, sex acts, pornography, nudity): publish none of it. */
+export function isExplicit(...texts: unknown[]): boolean;
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
-/** 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. */
+/** @deprecated (2026-10-31) AI text may describe people. Returns the text as given. */
export function noLooks<T>(text: T): T;
export function noLooks<T>(text: T): T;
-/** Tags that name no clothing, hair, face, age or gendered person. */
+/** @deprecated Returns the string tags as given. */
export function noLooksTags(tags: unknown): string[];
export function noLooksTags(tags: unknown): string[];
-/** True when a short text (a title) names looks at all: replace it rather than repair it. */
+/** @deprecated Always false. */
export function hasLooks(text: unknown): boolean;
export function hasLooks(text: unknown): boolean;
-/** True when any of the texts says a scene shows sexual content (body parts, sex acts, pornography, nudity): publish none of it. */
+/** True when any of the texts says a scene shows sexual content: publish none of it. Prefer openvibe-sdk/explicit. */
export function isExplicit(...texts: unknown[]): boolean;
export function isExplicit(...texts: unknown[]): boolean;
-/** A 16-hex-digit hash of the rules: record it after repairing stored rows, and repair again when it changes. */
+/** @deprecated 'off'. */
export const RULES_VERSION: string;
export const RULES_VERSION: string;