openvibers/openvibe.community Commits
Public AI text never describes a person: every AI summary, tag and AI paste title/body passes openvibe-sdk/no-looks (v0.61.0), and stored rows are repaired once (187 summaries named people; 478 '@null' footers) (#117)
Changes
@@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.60.0 (events outbox, per-actor limits),++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.61.0 (events outbox, per-actor limits, no-looks),`openvibe-shared` v4.4.0, pinned by release tarball## How it fits the network@@@ -845,6 +845,6 @@ release after paste comments moved onto the typed comment threads. [Submissions]<!-- versions:start -->- openvibe-contracts: v0.209.0--- openvibe-sdk: v0.60.0++- openvibe-sdk: v0.61.0- openvibe-shared: v4.4.0<!-- versions:end -->@@@ -8,7 +8,7 @@"port": 4200,"domain": "openvibe.community","contracts": "openvibe-contracts v0.209.0",-- "sdk": "openvibe-sdk v0.60.0",++ "sdk": "openvibe-sdk v0.61.0","shared": "openvibe-shared v4.4.0","capabilities": ["community.paste.create",@@@ -18,7 +18,7 @@"iovalkey": "^0.4.0","multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0","pg": "^8.23.0"},@@@ -933,9 +933,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.60.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",-- "integrity": "sha512-hhgpGeEZsoGh+mGWcY22ZOXL+0vTNX+q7PtaAzxwNtr57K2XgCCHl2WqRpjHjrzyxPsjOOr9LBo3VYnmNv18bw==",++ "version": "0.61.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",++ "integrity": "sha512-rhQKwM+yzVjhgUPTeqMOkL9N38fbSy8JYiEbJ8SwZk8LGcusde6LoQNhQ8UUxMJIwyY7xR1Rp5NVd6XRaVgTQQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -29,7 +29,7 @@"iovalkey": "^0.4.0","multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0","pg": "^8.23.0"},@@@ -35,6 +35,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkconsole.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);});server.keepAliveTimeout = 65_000;++ // Public AI text never describes a person: rows stored before the rule are repaired once listening++ // (server/pastes/no-looks.js; a boot with nothing to repair only reads).++ require('./pastes/no-looks').repairStored(require('./db').getDb()).catch((err) => console.warn('[Pastes] AI text repair skipped:', err.message));// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.telemetry.init({ service: 'community', sink: createPgTelemetrySink({ db: require('./db').getDb(), log: console }), skipPrefixes: ['/shared'], intervalMs: 60_000 });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * Public AI text never describes a person (the owner's rule; openvibe-sdk/no-looks): no looks, body, clothing, hair,++ * face, pose, age or guessed gender. Services send Community AI text (Live's picture descriptions of screenshots, its++ * AI moment pastes), and models ignore the rule in their prompts about one time in ten, so Community applies it to++ * every AI field it stores:++ *++ * - a paste's ai_summary and ai_tags, whoever owns the paste;++ * - the title and body of an AI paste (origin 'ai'). The body's footer ("🔴 Caught live on @x's stream — …",++ * "▶ Watch this moment on …") is links, not description, and stays as written, except "on @null's stream",++ * which reads "on the stream".++ *++ * repairStored() rewrites rows stored before (187 of 1,207 summaries named people on 2026-10-22). It runs at boot++ * and changes only rows whose text the rules change, so a later boot finds nothing. Search's hourly refresh++ * (server/search/documents.js) re-sends the repaired pastes. A person's own title and text are never touched.++ */++const { noLooks, noLooksTags } = require('openvibe-sdk/no-looks');++++const FOOTER = /(\n\n(?:🔴|▶)[\s\S]*)$/u;++++const summary = (s) => (typeof s === 'string' ? noLooks(s).trim() : s);++++/** ai_tags as stored (a JSON array string, an array, or comma text) → the same shape without looks tags. */++function tags(t) {++ if (t == null) return t;++ if (Array.isArray(t)) return noLooksTags(t);++ const s = String(t);++ try {++ const arr = JSON.parse(s);++ if (Array.isArray(arr)) return JSON.stringify(noLooksTags(arr));++ } catch { /* comma text */ }++ return noLooksTags(s.split(',').map((x) => x.trim())).join(', ');++}++++/** An AI paste's body: the description before its footer. */++function body(content) {++ if (typeof content !== 'string') return content;++ const m = FOOTER.exec(content);++ const text = m ? content.slice(0, m.index) : content;++ const clean = noLooks(text).trim();++ // Live wrote "on @null's stream" when Media's list named no owner (478 pastes, 2026-08-18 to 10-22; fixed in Live).++ const footer = m ? m[1].replace(/ on @null's stream:/, ' on the stream:') : '';++ // A description that was all looks leaves the footer alone.++ return clean ? clean + footer : footer.trim();++}++++/** An AI paste's title; '' when it was all looks (store.sanitizeTitle then gives the default). */++function title(t) {++ if (typeof t !== 'string') return t;++ return noLooks(t).trim();++}++++/** The AI fields of a row as Community stores them: { ai_summary, ai_tags } and, for an AI paste, title and content. */++function cleanRow(row) {++ const out = {};++ if (row.ai_summary != null) out.ai_summary = summary(row.ai_summary);++ if (row.ai_tags != null) out.ai_tags = tags(row.ai_tags);++ if (row.origin === 'ai') {++ if (row.title != null) out.title = title(row.title) || 'Untitled';++ if (row.content != null) out.content = body(row.content);++ }++ return out;++}++++/** Rewrite stored rows the rules change; returns how many changed. */++async function repairStored(db, { log = console } = {}) {++ const rows = await db.prepare(`SELECT id, origin, title, content, ai_summary, ai_tags FROM pastes++ WHERE deleted_at IS NULL AND (ai_summary IS NOT NULL OR ai_tags IS NOT NULL OR origin = 'ai')`).all();++ let changed = 0;++ for (const row of rows) {++ const clean = cleanRow(row);++ const cols = Object.keys(clean).filter((c) => clean[c] !== row[c]);++ if (!cols.length) continue;++ await db.prepare(`UPDATE pastes SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`).run(...cols.map((c) => clean[c]), row.id);++ changed++;++ }++ if (changed && log) log.log(`[Pastes] public AI text no longer describes people: ${changed} paste(s) repaired`);++ return changed;++}++++module.exports = { summary, tags, body, title, cleanRow, repairStored };@@@ -18,6 +18,7 @@const crypto = require('crypto');const events = require('../events');const store = require('./store');++const looks = require('./no-looks');const commentStore = require('../comments/store');const { discussionModerator } = require('../identity/capabilities');const { stripImageMetadata } = require('../media/strip-metadata');@@@ -284,7 +285,8 @@ function createPasteService({ db, network = null, media = null, config = {}, limfunction aiFields(v, body) {if (!isService(v) || body.ai_summary == null) return {};const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);-- return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };++ // Public AI text never describes a person (./no-looks.js).++ return { ai_summary: looks.summary(String(body.ai_summary).slice(0, 2000)), ai_tags: looks.tags(tags), ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };}/**@@@ -410,8 +412,9 @@ function createPasteService({ db, network = null, media = null, config = {}, limowner_subject: v.subject || null,origin: v.origin === 'ai' ? 'ai' : 'user',type: 'paste',-- title: store.sanitizeTitle(body.title),-- content: content.trim(),++ // An AI paste's title and body are AI text too; a person's are theirs, as written.++ title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title) : body.title),++ content: v.origin === 'ai' ? looks.body(content.trim()) : content.trim(),language: store.detectLanguage(content, body.language),visibility,stream_ref: streamRefFor(v, body),@@@ -457,8 +460,8 @@ function createPasteService({ db, network = null, media = null, config = {}, limowner_subject: v.subject || null,origin: v.origin === 'ai' ? 'ai' : 'user',type: 'screenshot',-- title: store.sanitizeTitle(body.title || 'Screenshot'),-- content: String(body.description || body.content || ''),++ title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title || 'Screenshot') : (body.title || 'Screenshot')),++ content: v.origin === 'ai' ? looks.body(String(body.description || body.content || '')) : String(body.description || body.content || ''),language: 'text',visibility,screenshot_url: stored.url,@@@ -712,8 +715,8 @@ function createPasteService({ db, network = null, media = null, config = {}, limasync setAi(v, slug, body = {}) {const p = await store.getBySlug(db, String(slug));if (!p) fail(404, 'Paste not found');-- const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);-- const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);++ const summary = body.ai_summary == null ? null : looks.summary(String(body.ai_summary).slice(0, 2000));++ const tags = body.ai_tags == null ? null : looks.tags((typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000));const row = await store.setAi(db, p.id, summary, tags);pingIndexNow(row);return { ok: true, paste: await shapeOne(row, v) };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Public AI text never describes a person (server/pastes/no-looks.js, openvibe-sdk/no-looks): a service's AI summary++ * and tags, and an AI paste's title and body, are stored without looks or a guessed gender; a person's own title and++ * text are theirs as written; rows stored before are repaired at boot, once.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const looks = require('../server/pastes/no-looks');++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const svc = (cap) => net.signService({ cap });++ const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {++ const h = { ...headers };++ if (token) h.authorization = `Bearer ${token}`;++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++++ await check('an AI paste from a service: title, body, summary and tags carry no looks; the footer stays', async () => {++ const r = await call('/api/pastes', {++ method: 'POST', token: svc(['community.paste.create']), headers: { 'x-ov-origin': 'ai' },++ json: {++ title: 'A Shirtless Man Cooks', slug: 'cooks-1',++ content: 'A shirtless man in a red hoodie cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef',++ ai_summary: 'A man wearing a hat cooks pasta.', ai_tags: ['man', 'pasta', 'red-hoodie'],++ },++ });++ assert.strictEqual(r.status, 201, r.text);++ const p = r.json().paste;++ assert.strictEqual(p.title, 'The streamer Cooks');++ assert.strictEqual(p.content, 'The streamer cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef');++ assert.strictEqual(p.ai_summary, 'A person cooks pasta.');++ assert.deepStrictEqual(JSON.parse(p.ai_tags), ['pasta']);++ });++++ await check('a person\'s paste: their title and text as written; the AI summary of it follows the rule', async () => {++ const r = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { title: 'A man and his dog', content: 'A man in a red shirt walks his dog.' } });++ assert.strictEqual(r.status, 201, r.text);++ const slug = r.json().slug;++ assert.strictEqual(r.json().paste.title, 'A man and his dog');++ assert.strictEqual(r.json().paste.content, 'A man in a red shirt walks his dog.');++ const w = await call(`/api/pastes/${slug}/ai`, { method: 'POST', token: svc(['community.paste.moderate']), json: { ai_summary: 'Close-up selfie of a man, taken outdoors.', ai_tags: '["selfie","outdoors","beard"]' } });++ assert.strictEqual(w.status, 200, w.text);++ assert.strictEqual(w.json().paste.ai_summary, 'Close-up selfie of a person, taken outdoors.');++ assert.deepStrictEqual(JSON.parse(w.json().paste.ai_tags), ['outdoors']);++ const row = await t.db.prepare('SELECT title, content FROM pastes WHERE slug = ?').get(slug);++ assert.deepStrictEqual([row.title, row.content], ['A man and his dog', 'A man in a red shirt walks his dog.']);++ });++++ await check('rows stored before the rule are repaired once; a person\'s text is untouched; a second pass changes nothing', async () => {++ const ins = (origin, title, content, summary, tags) => t.db.prepare(`INSERT INTO pastes (slug, origin, type, title, content, language, visibility, ai_summary, ai_tags)++ VALUES (?, ?, 'paste', ?, ?, 'text', 'public', ?, ?)`).run(`old-${Math.random().toString(36).slice(2, 10)}`, origin, title, content, summary, tags);++ await ins('ai', 'A Close-Up Selfie Moment Where A Man Looks Straight', 'Close-up selfie of a man, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1', 'Close-up selfie of a man, taken outdoors with trees in the background.', '["man","trees"]');++ await ins('user', 'Two men at a desk', 'Two men at a desk.', 'A man lounging on a sofa, holding a remote.', 'sofa, man');++ await ins('user', 'Notes', 'my notes', 'A list of notes about a game.', null);++ const n = await looks.repairStored(t.db, { log: null });++ assert.strictEqual(n, 2);++ const ai = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title LIKE 'A Close-Up%'").get();++ assert.strictEqual(ai.title, 'A Close-Up Selfie Moment Where A person Looks Straight');++ assert.strictEqual(ai.content, 'Close-up selfie of a person, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1');++ assert.strictEqual(ai.ai_summary, 'Close-up selfie of a person, taken outdoors with trees in the background.');++ assert.strictEqual(ai.ai_tags, '["trees"]');++ const user = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title = 'Two men at a desk'").get();++ assert.deepStrictEqual([user.title, user.content], ['Two men at a desk', 'Two men at a desk.'], 'a person\'s own words stay');++ assert.strictEqual(user.ai_summary, 'A person lounging on a sofa, holding a remote.');++ assert.strictEqual(user.ai_tags, 'sofa');++ assert.strictEqual(await looks.repairStored(t.db, { log: null }), 0, 'nothing left to repair');++ });++++ await check('helpers: non-strings pass through; a body or title that is all looks leaves nothing of it', async () => {++ assert.strictEqual(looks.summary(null), null);++ assert.strictEqual(looks.tags(null), null);++ assert.deepStrictEqual(looks.tags(['hat', 'game']), ['game']);++ assert.strictEqual(looks.body('A person adjusts their hair.\n\n🔴 Caught live on @x\'s stream'), '🔴 Caught live on @x\'s stream', 'a description that was all looks leaves the footer');++ assert.strictEqual(looks.body('A person adjusts their hair.'), '');++ assert.strictEqual(looks.body("A desk.\n\n▶ Watch this moment on @null's stream: https://openvibe.video/v/9"), 'A desk.\n\n▶ Watch this moment on the stream: https://openvibe.video/v/9');++ assert.strictEqual(looks.cleanRow({ origin: 'ai', title: 'A person adjusts their hair' }).title, 'Untitled');++ });++++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
-- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.60.0 (events outbox, per-actor limits),
+- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.61.0 (events outbox, per-actor limits, no-looks),
`openvibe-shared` v4.4.0, pinned by release tarball
`openvibe-shared` v4.4.0, pinned by release tarball
## How it fits the network
## How it fits the network
@@ -845,6 +845,6 @@ release after paste comments moved onto the typed comment threads. [Submissions]
@@ -845,6 +845,6 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.209.0
- openvibe-contracts: v0.209.0
-- openvibe-sdk: v0.60.0
+- openvibe-sdk: v0.61.0
- openvibe-shared: v4.4.0
- openvibe-shared: v4.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4200,
"port": 4200,
"domain": "openvibe.community",
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.209.0",
"contracts": "openvibe-contracts v0.209.0",
- "sdk": "openvibe-sdk v0.60.0",
+ "sdk": "openvibe-sdk v0.61.0",
"shared": "openvibe-shared v4.4.0",
"shared": "openvibe-shared v4.4.0",
"capabilities": [
"capabilities": [
"community.paste.create",
"community.paste.create",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -933,9 +933,9 @@
@@ -933,9 +933,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.60.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
- "integrity": "sha512-hhgpGeEZsoGh+mGWcY22ZOXL+0vTNX+q7PtaAzxwNtr57K2XgCCHl2WqRpjHjrzyxPsjOOr9LBo3VYnmNv18bw==",
+ "version": "0.61.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
+ "integrity": "sha512-rhQKwM+yzVjhgUPTeqMOkL9N38fbSy8JYiEbJ8SwZk8LGcusde6LoQNhQ8UUxMJIwyY7xR1Rp5NVd6XRaVgTQQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -35,6 +35,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -35,6 +35,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
+ // Public AI text never describes a person: rows stored before the rule are repaired once listening
+ // (server/pastes/no-looks.js; a boot with nothing to repair only reads).
+ require('./pastes/no-looks').repairStored(require('./db').getDb()).catch((err) => console.warn('[Pastes] AI text repair skipped:', err.message));
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.
// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.
telemetry.init({ service: 'community', sink: createPgTelemetrySink({ db: require('./db').getDb(), log: console }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
telemetry.init({ service: 'community', sink: createPgTelemetrySink({ db: require('./db').getDb(), log: console }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Public AI text never describes a person (the owner's rule; openvibe-sdk/no-looks): no looks, body, clothing, hair,
+ * face, pose, age or guessed gender. Services send Community AI text (Live's picture descriptions of screenshots, its
+ * AI moment pastes), and models ignore the rule in their prompts about one time in ten, so Community applies it to
+ * every AI field it stores:
+ *
+ * - a paste's ai_summary and ai_tags, whoever owns the paste;
+ * - the title and body of an AI paste (origin 'ai'). The body's footer ("🔴 Caught live on @x's stream — …",
+ * "▶ Watch this moment on …") is links, not description, and stays as written, except "on @null's stream",
+ * which reads "on the stream".
+ *
+ * repairStored() rewrites rows stored before (187 of 1,207 summaries named people on 2026-10-22). It runs at boot
+ * and changes only rows whose text the rules change, so a later boot finds nothing. Search's hourly refresh
+ * (server/search/documents.js) re-sends the repaired pastes. A person's own title and text are never touched.
+ */
+const { noLooks, noLooksTags } = require('openvibe-sdk/no-looks');
+
+const FOOTER = /(\n\n(?:🔴|▶)[\s\S]*)$/u;
+
+const summary = (s) => (typeof s === 'string' ? noLooks(s).trim() : s);
+
+/** ai_tags as stored (a JSON array string, an array, or comma text) → the same shape without looks tags. */
+function tags(t) {
+ if (t == null) return t;
+ if (Array.isArray(t)) return noLooksTags(t);
+ const s = String(t);
+ try {
+ const arr = JSON.parse(s);
+ if (Array.isArray(arr)) return JSON.stringify(noLooksTags(arr));
+ } catch { /* comma text */ }
+ return noLooksTags(s.split(',').map((x) => x.trim())).join(', ');
+}
+
+/** An AI paste's body: the description before its footer. */
+function body(content) {
+ if (typeof content !== 'string') return content;
+ const m = FOOTER.exec(content);
+ const text = m ? content.slice(0, m.index) : content;
+ const clean = noLooks(text).trim();
+ // Live wrote "on @null's stream" when Media's list named no owner (478 pastes, 2026-08-18 to 10-22; fixed in Live).
+ const footer = m ? m[1].replace(/ on @null's stream:/, ' on the stream:') : '';
+ // A description that was all looks leaves the footer alone.
+ return clean ? clean + footer : footer.trim();
+}
+
+/** An AI paste's title; '' when it was all looks (store.sanitizeTitle then gives the default). */
+function title(t) {
+ if (typeof t !== 'string') return t;
+ return noLooks(t).trim();
+}
+
+/** The AI fields of a row as Community stores them: { ai_summary, ai_tags } and, for an AI paste, title and content. */
+function cleanRow(row) {
+ const out = {};
+ if (row.ai_summary != null) out.ai_summary = summary(row.ai_summary);
+ if (row.ai_tags != null) out.ai_tags = tags(row.ai_tags);
+ if (row.origin === 'ai') {
+ if (row.title != null) out.title = title(row.title) || 'Untitled';
+ if (row.content != null) out.content = body(row.content);
+ }
+ return out;
+}
+
+/** Rewrite stored rows the rules change; returns how many changed. */
+async function repairStored(db, { log = console } = {}) {
+ const rows = await db.prepare(`SELECT id, origin, title, content, ai_summary, ai_tags FROM pastes
+ WHERE deleted_at IS NULL AND (ai_summary IS NOT NULL OR ai_tags IS NOT NULL OR origin = 'ai')`).all();
+ let changed = 0;
+ for (const row of rows) {
+ const clean = cleanRow(row);
+ const cols = Object.keys(clean).filter((c) => clean[c] !== row[c]);
+ if (!cols.length) continue;
+ await db.prepare(`UPDATE pastes SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`).run(...cols.map((c) => clean[c]), row.id);
+ changed++;
+ }
+ if (changed && log) log.log(`[Pastes] public AI text no longer describes people: ${changed} paste(s) repaired`);
+ return changed;
+}
+
+module.exports = { summary, tags, body, title, cleanRow, repairStored };
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const events = require('../events');
const events = require('../events');
const store = require('./store');
const store = require('./store');
+const looks = require('./no-looks');
const commentStore = require('../comments/store');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
@@ -284,7 +285,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -284,7 +285,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
function aiFields(v, body) {
function aiFields(v, body) {
if (!isService(v) || body.ai_summary == null) return {};
if (!isService(v) || body.ai_summary == null) return {};
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
- return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
+ // Public AI text never describes a person (./no-looks.js).
+ return { ai_summary: looks.summary(String(body.ai_summary).slice(0, 2000)), ai_tags: looks.tags(tags), ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
}
}
/**
/**
@@ -410,8 +412,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -410,8 +412,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
owner_subject: v.subject || null,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
type: 'paste',
- title: store.sanitizeTitle(body.title),
- content: content.trim(),
+ // An AI paste's title and body are AI text too; a person's are theirs, as written.
+ title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title) : body.title),
+ content: v.origin === 'ai' ? looks.body(content.trim()) : content.trim(),
language: store.detectLanguage(content, body.language),
language: store.detectLanguage(content, body.language),
visibility,
visibility,
stream_ref: streamRefFor(v, body),
stream_ref: streamRefFor(v, body),
@@ -457,8 +460,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -457,8 +460,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
owner_subject: v.subject || null,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'screenshot',
type: 'screenshot',
- title: store.sanitizeTitle(body.title || 'Screenshot'),
- content: String(body.description || body.content || ''),
+ title: store.sanitizeTitle(v.origin === 'ai' ? looks.title(body.title || 'Screenshot') : (body.title || 'Screenshot')),
+ content: v.origin === 'ai' ? looks.body(String(body.description || body.content || '')) : String(body.description || body.content || ''),
language: 'text',
language: 'text',
visibility,
visibility,
screenshot_url: stored.url,
screenshot_url: stored.url,
@@ -712,8 +715,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -712,8 +715,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async setAi(v, slug, body = {}) {
async setAi(v, slug, body = {}) {
const p = await store.getBySlug(db, String(slug));
const p = await store.getBySlug(db, String(slug));
if (!p) fail(404, 'Paste not found');
if (!p) fail(404, 'Paste not found');
- const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
- const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
+ const summary = body.ai_summary == null ? null : looks.summary(String(body.ai_summary).slice(0, 2000));
+ const tags = body.ai_tags == null ? null : looks.tags((typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000));
const row = await store.setAi(db, p.id, summary, tags);
const row = await store.setAi(db, p.id, summary, tags);
pingIndexNow(row);
pingIndexNow(row);
return { ok: true, paste: await shapeOne(row, v) };
return { ok: true, paste: await shapeOne(row, v) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Public AI text never describes a person (server/pastes/no-looks.js, openvibe-sdk/no-looks): a service's AI summary
+ * and tags, and an AI paste's title and body, are stored without looks or a guessed gender; a person's own title and
+ * text are theirs as written; rows stored before are repaired at boot, once.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const looks = require('../server/pastes/no-looks');
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const svc = (cap) => net.signService({ cap });
+ const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {
+ const h = { ...headers };
+ if (token) h.authorization = `Bearer ${token}`;
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+
+ await check('an AI paste from a service: title, body, summary and tags carry no looks; the footer stays', async () => {
+ const r = await call('/api/pastes', {
+ method: 'POST', token: svc(['community.paste.create']), headers: { 'x-ov-origin': 'ai' },
+ json: {
+ title: 'A Shirtless Man Cooks', slug: 'cooks-1',
+ content: 'A shirtless man in a red hoodie cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef',
+ ai_summary: 'A man wearing a hat cooks pasta.', ai_tags: ['man', 'pasta', 'red-hoodie'],
+ },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = r.json().paste;
+ assert.strictEqual(p.title, 'The streamer Cooks');
+ assert.strictEqual(p.content, 'The streamer cooks pasta.\n\n🔴 Caught live on @chef\'s stream — https://openvibe.live/@chef');
+ assert.strictEqual(p.ai_summary, 'A person cooks pasta.');
+ assert.deepStrictEqual(JSON.parse(p.ai_tags), ['pasta']);
+ });
+
+ await check('a person\'s paste: their title and text as written; the AI summary of it follows the rule', async () => {
+ const r = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { title: 'A man and his dog', content: 'A man in a red shirt walks his dog.' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const slug = r.json().slug;
+ assert.strictEqual(r.json().paste.title, 'A man and his dog');
+ assert.strictEqual(r.json().paste.content, 'A man in a red shirt walks his dog.');
+ const w = await call(`/api/pastes/${slug}/ai`, { method: 'POST', token: svc(['community.paste.moderate']), json: { ai_summary: 'Close-up selfie of a man, taken outdoors.', ai_tags: '["selfie","outdoors","beard"]' } });
+ assert.strictEqual(w.status, 200, w.text);
+ assert.strictEqual(w.json().paste.ai_summary, 'Close-up selfie of a person, taken outdoors.');
+ assert.deepStrictEqual(JSON.parse(w.json().paste.ai_tags), ['outdoors']);
+ const row = await t.db.prepare('SELECT title, content FROM pastes WHERE slug = ?').get(slug);
+ assert.deepStrictEqual([row.title, row.content], ['A man and his dog', 'A man in a red shirt walks his dog.']);
+ });
+
+ await check('rows stored before the rule are repaired once; a person\'s text is untouched; a second pass changes nothing', async () => {
+ const ins = (origin, title, content, summary, tags) => t.db.prepare(`INSERT INTO pastes (slug, origin, type, title, content, language, visibility, ai_summary, ai_tags)
+ VALUES (?, ?, 'paste', ?, ?, 'text', 'public', ?, ?)`).run(`old-${Math.random().toString(36).slice(2, 10)}`, origin, title, content, summary, tags);
+ await ins('ai', 'A Close-Up Selfie Moment Where A Man Looks Straight', 'Close-up selfie of a man, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1', 'Close-up selfie of a man, taken outdoors with trees in the background.', '["man","trees"]');
+ await ins('user', 'Two men at a desk', 'Two men at a desk.', 'A man lounging on a sofa, holding a remote.', 'sofa, man');
+ await ins('user', 'Notes', 'my notes', 'A list of notes about a game.', null);
+ const n = await looks.repairStored(t.db, { log: null });
+ assert.strictEqual(n, 2);
+ const ai = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title LIKE 'A Close-Up%'").get();
+ assert.strictEqual(ai.title, 'A Close-Up Selfie Moment Where A person Looks Straight');
+ assert.strictEqual(ai.content, 'Close-up selfie of a person, taken outdoors with trees in the background.\n\n▶ Watch this moment on @x\'s stream: https://openvibe.video/v/1');
+ assert.strictEqual(ai.ai_summary, 'Close-up selfie of a person, taken outdoors with trees in the background.');
+ assert.strictEqual(ai.ai_tags, '["trees"]');
+ const user = await t.db.prepare("SELECT title, content, ai_summary, ai_tags FROM pastes WHERE title = 'Two men at a desk'").get();
+ assert.deepStrictEqual([user.title, user.content], ['Two men at a desk', 'Two men at a desk.'], 'a person\'s own words stay');
+ assert.strictEqual(user.ai_summary, 'A person lounging on a sofa, holding a remote.');
+ assert.strictEqual(user.ai_tags, 'sofa');
+ assert.strictEqual(await looks.repairStored(t.db, { log: null }), 0, 'nothing left to repair');
+ });
+
+ await check('helpers: non-strings pass through; a body or title that is all looks leaves nothing of it', async () => {
+ assert.strictEqual(looks.summary(null), null);
+ assert.strictEqual(looks.tags(null), null);
+ assert.deepStrictEqual(looks.tags(['hat', 'game']), ['game']);
+ assert.strictEqual(looks.body('A person adjusts their hair.\n\n🔴 Caught live on @x\'s stream'), '🔴 Caught live on @x\'s stream', 'a description that was all looks leaves the footer');
+ assert.strictEqual(looks.body('A person adjusts their hair.'), '');
+ assert.strictEqual(looks.body("A desk.\n\n▶ Watch this moment on @null's stream: https://openvibe.video/v/9"), 'A desk.\n\n▶ Watch this moment on the stream: https://openvibe.video/v/9');
+ assert.strictEqual(looks.cleanRow({ origin: 'ai', title: 'A person adjusts their hair' }).title, 'Untitled');
+ });
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });