openvibers/openvibe.ai Commits
AI text may describe people; only sex acts and porn stay gated (#218)
Changes
@@@ -37,7 +37,7 @@* - Two active items that are near each other but disagree (cosine 0.75 to 0.92) are judged by one cheap-tier* call that sees only those two notes, and both are marked `status = 'conflict'` with a `conflict_with` link:* a conflict never reaches an agent. The person settles it (POST /items/:id/settle { keep }).-- * - The profile is built from at most 60 active notes, newest first, passes noLooks and is at most about a++ * - The profile is built from at most 60 active notes, newest first, is stored as written and is at most about a* thousand tokens. It is cached by items_hash and rebuilt at most once an hour per owner and scope.* - The store's own maintenance runs in the background (server/memory/jobs.js): owner by owner, with a per-run* cap, off in test mode and off in a drill.@@@ -47,7 +47,6 @@ const express = require('express');const { ids, http, capabilities } = require('openvibe-contracts');const { verifyUserToken } = require('openvibe-sdk/auth');const { redact, onlySecret } = require('openvibe-sdk/redact');--const { noLooks } = require('openvibe-sdk/no-looks');const { AiError, iso, sha256 } = require('../util');const { bearer, isAppClaims, principalSubject } = require('../auth');@@@ -56,7 +55,7 @@ const SCOPE_RE = /^(user|project:[a-z0-9][a-z0-9._-]{0,63})$/;const SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];const STATUSES = ['active', 'suggested', 'archived', 'conflict'];--/** Items from these sources were produced by an AI, so they are suggestions and pass noLooks. */++/** Items from these sources were produced by an AI, so they are suggestions and are stored as written. */const AI_SOURCES = new Set(['actor', 'vibe', 'know']);const PROFILE_INTERVAL_MS = 60 * 60 * 1000;/* M2c: the meaning of an item. Two items of one owner and scope that say the same thing (cosine at least@@@ -469,12 +468,12 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con? req.principal.subject.id: String(req.principal.sub || '').replace(/^svc:/, '');const source = validateSource(serviceCaller ? serviceSource : (raw.source || 'person'));-- // A service is an agent: what it writes is a suggestion, and it passes noLooks. A person may mark their own-- // item as AI-produced too (the Node imports what a coding tool wrote).++ // A service is an agent: what it writes is a suggestion, stored as written. A person may mark their own item++ // as AI-produced too (the Node imports what a coding tool wrote).const aiMade = serviceCaller || raw.no_looks === true || AI_SOURCES.has(source);-- let text = aiMade ? noLooks(clean) : clean;++ let text = clean;text = String(text == null ? '' : text);-- if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty after no-looks`);++ if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty`);if (onlySecret(text)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);const askedConfidence = raw.confidence == null ? null : Number(raw.confidence);const confidence = askedConfidence == null || !Number.isFinite(askedConfidence)@@@ -589,7 +588,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conconst clean = redact(String(body.text)).text;if (!clean.trim()) throw new AiError(422, 'input.invalid', 'text is empty');if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');-- nextText = body.no_looks === true ? String(noLooks(clean)) : clean;++ nextText = clean;if (onlySecret(nextText)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');add('text_enc', seal(nextText, aadFor(owner, row.id, nextScope)));add('text_mac', macOf(nextText));@@@ -670,7 +669,6 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conconst prompt = [`Write a short profile of the person from their own memory notes for ${scope}.`,'Rules: summarize only what the notes say. Never guess or infer facts about the person.',-- 'Never describe looks, body, clothing, hair, face, pose, age or a guessed gender: no looks, at any time.','Write at most 600 words and do not add a title.','',notes,@@@ -682,7 +680,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conconst route = await registry.resolveRoute('default.chat');if (!route || route.disabled) throw new AiError(503, 'memory.profile_failed', 'no route is available to build a profile');const exec = await pool.execute(route, 'summarize', {-- system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess, never describe looks or a body, and never add a fact the notes do not state.', cache: true }],++ system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess and never add a fact the notes do not state.', cache: true }],messages: [{ role: 'user', content: prompt }],maxTokens: PROFILE_MAX_TOKENS,temperature: 0.2,@@@ -718,7 +716,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conreturn { profile: { scope, text: '', items_hash: hash, built_at: builtAt }, rebuilt: true };}const items = rows.map((r) => ({ kind: r.kind, text: open(r.text_enc, aadFor(owner, r.id, r.scope)) }));-- const text = String(noLooks(await summarize({ owner, scope, items })) || '').slice(0, PROFILE_MAX_CHARS);++ const text = String((await summarize({ owner, scope, items })) || '').slice(0, PROFILE_MAX_CHARS);const builtAt = iso(now);await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)@@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {});route('get', '/memory/profile', {operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,-- description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result passes noLooks.',++ description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',parameters: [query('scope', str()), query('on_behalf_of', str())],responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },});@@@ -52,7 +52,7 @@ const templates = [description: 'From ai-analysis.analyzeStreamFrame (stream_memory): memory description, tags, and a screenshot-worthiness verdict + caption for live pastes, in one vision call.',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'This is a frame from a live stream. Describe what is happening (the game, the activity, the scene, text on screen), not what people look like: never mention anyone\'s body, clothing, age, ethnicity or perceived gender, and call a person "the streamer". Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a big reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption naming what happens, never what someone looks like or wears (no hats, beanies, shirts, glasses, faces or hair), else empty>"}.',++ user_prompt: 'This is a frame from a live stream. Describe what is happening (the game, the activity, the scene, text on screen), people included, and call a person "the streamer". Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a big reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption naming what happens , people included, else empty>"}.',default_route: 'live.vision', owner: 'live',},{@@@ -60,7 +60,7 @@ const templates = [description: 'From ai-analysis.summarizeStreamMemories: whole-session overview from timestamped observations and the audio timeline; category is inferred, never defaulted.',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'These are timestamped observations from a live stream, in order since it started. Write about the activities, topics and vibe, never about what people look like (body, clothing, age, ethnicity or perceived gender); call a person "the streamer". Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',++ user_prompt: 'These are timestamped observations from a live stream, in order since it started. Write about the activities, topics and vibe, people included; call a person "the streamer". Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',default_route: 'live.legacy', owner: 'live',},{@@@ -68,7 +68,7 @@ const templates = [description: 'From ai-analysis.generateStreamerOverview.',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name, and after that refer to them by name or as "they"; never guess pronouns.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender, and never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',++ user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name, and after that refer to them by name or as "they"; never guess pronouns.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',default_route: 'live.legacy', owner: 'live',},{@@@ -76,7 +76,7 @@ const templates = [description: 'From media-analysis.analyzeMedia (media_overview).',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown. Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender.\n\n{{parts}}',++ user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown.\n\n{{parts}}',default_route: 'live.legacy', owner: 'live',},{@@@ -120,7 +120,7 @@ const templates = [description: "From rundown/desk.js: a stretch of one stream's speech-to-text lines in; the segments of what the streamer talked about out (topic, headline, what, take, the desk's quip, verbatim quotes).",input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["others", "segments"], "properties": {"others": {"type": "array", "maxItems": 400, "items": {"type": "number"}}, "segments": {"type": "array", "maxItems": 4, "items": {"type": "object", "additionalProperties": false, "required": ["start", "end", "topic", "kind", "title", "what", "take", "quip", "score", "quotes"], "properties": {"start": {"type": "number"}, "end": {"type": "number"}, "topic": {"type": "string"}, "kind": {"type": "string", "enum": ["news", "games", "tech", "life", "music", "sports", "money", "internet", "chat", "other"]}, "title": {"type": "string"}, "what": {"type": "string"}, "take": {"type": ["string", "null"]}, "quip": {"type": "string"}, "score": {"type": "integer", "minimum": 1, "maximum": 5}, "quotes": {"type": "array", "maxItems": 2, "items": {"type": "object", "additionalProperties": false, "required": ["t", "text", "en"], "properties": {"t": {"type": "number"}, "text": {"type": "string"}, "en": {"type": ["string", "null"]}}}}}}}}},-- system_prompt: "You are the writers' room of THE RUNDOWN, the part of a small live-streaming site that turns what streamers say on stream into stories people want to read. You get an automatic transcript of a stretch of one live stream and pull out what the streamer actually talked about. The transcript is speech recognition of the stream's whole audio: it mixes the streamer's own voice with game sounds, announcers and characters, videos and songs they play, articles they read aloud and other people, and it mishears words and sometimes the language. Rules: (0) First decide who is speaking on every line, and list in others the t of every line that is NOT the streamer (a game's announcer or characters, a video or clip, a song, an article read aloud, other people); build segments and pick quotes only from the streamer's own lines. (1) Only the streamer's own talk is theirs: their opinions, stories, rants, jokes, plans, reactions and conversations with chat. A game's announcer or character lines (\"You have slain an enemy\", \"First blood\", \"Victory\"), lyrics, ads, a video's narration or an article read aloud are never the streamer; when they react to an article or a video, its subject is the topic and their reaction is the take. A streamer who talks in one language most of the time does not break into short theatrical one-liners in another: dramatic lines in a different language (often English) dropped between their own talk, like \"A great queen needs tenacity.\" or \"Fear what I'll do now.\", are the game's characters, not roleplay or the streamer, so they are never a segment, a take or a quote. In a game stream, short lines in a character's voice (boasts, threats, catchphrases, solemn or poetic lines such as \"Kneel before me or bleed out.\", \"No one can find the queen!\", \"Only those you love can break your heart.\" or \"I would cut off my wings if there were steel strong enough.\") are the game even in English: the streamer is not roleplaying or being poetic. A sports game's commentators (\"We present…\", play-by-play, player names and tactics read out) are the game too. Lines that do not make sense (\"GCC embedded diablo, but controlling topic albanese\") are mistranscriptions: skip them and never invent a meaning for them. (2) Split the stretch into segments, one per thing they talked about, at most 4, in order. Skip stretches that are only gameplay noise, mumbling or garbled text; an empty list is a good answer when nothing was said. Play-by-play is not a segment: reacting to kills, deaths, teammates, plays, rounds or a fight as it happens is the game itself, and the stream's recap covers it. A segment about a game needs something a reader could follow without watching: an opinion (a champion is broken, matchmaking is rigged), a story, a plan, a running bit, an argument. A stretch that is only play-by-play gets at most one segment, and only for a standout moment. Never make a segment that is more of what Earlier in this stream already covered. (3) topic: a short concrete name for the subject, the way a headline names it (\"Supermarket fridges hacked in France\", \"League ranked matchmaking\", \"Moving apartments\"), never vague (\"news\", \"gaming\", \"chatting\", \"the stream\"), never one of the kinds below (\"chat\", \"games\", \"life\"…) and never a mood or a description of the stream (\"Frustration and chaos in the match\", \"Talking with chat and teammates\", \"Mid-game rage\"): name the subject, for a game the game and the thing (\"Briar in League\", \"League autofill\", \"Minecraft base building\"), for something they watched what it was (\"Boxing fight night\"); when nothing more specific fits, name what they were doing (\"Theme park rides\"). When it is the same specific subject as one of the known topics, return that name exactly; never file a segment under a known topic only because it is the same game or the same mood. (4) kind: news, games, tech, life, music, sports, money, internet, chat (talking with chat about the stream itself) or other. (5) title: a punchy headline for the segment, at most 70 characters. what: what they talked about, plainly, one or two sentences. take: their opinion or stance in one sentence, or null when they had none. quip: one line from the desk about the take or the situation, dry and a little savage, the way a late-night host would rib it, at most 160 characters. score: how much a reader who missed the stream would enjoy reading it, 1 (filler) to 5 (the best thing said all night): play-by-play and in-game reactions are 2 at most unless something standout happened, most segments are a 3, and a 4 or 5 needs a real opinion, story or line. (6) quotes: 0 to 2 lines of the streamer's own words, copied character for character from the transcript lines (part of one line is fine), the funniest or most telling, each with its t. A quote is something they said themselves: never a sentence they read out from an article, a post, a tweet, a chat message or a video, even when it is in quotation marks in the source; when unsure, leave it out. When a quote is not in English, en is an English translation; otherwise en is null. (7) start and end are seconds from the lines. Write topic, title, what, take and quip in English whatever language they speak. In what and take, call the streamer by the name given in Streamer, never \"the streamer\" or \"Streamer\". (8) Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",++ system_prompt: "You are the writers' room of THE RUNDOWN, the part of a small live-streaming site that turns what streamers say on stream into stories people want to read. You get an automatic transcript of a stretch of one live stream and pull out what the streamer actually talked about. The transcript is speech recognition of the stream's whole audio: it mixes the streamer's own voice with game sounds, announcers and characters, videos and songs they play, articles they read aloud and other people, and it mishears words and sometimes the language. Rules: (0) First decide who is speaking on every line, and list in others the t of every line that is NOT the streamer (a game's announcer or characters, a video or clip, a song, an article read aloud, other people); build segments and pick quotes only from the streamer's own lines. (1) Only the streamer's own talk is theirs: their opinions, stories, rants, jokes, plans, reactions and conversations with chat. A game's announcer or character lines (\"You have slain an enemy\", \"First blood\", \"Victory\"), lyrics, ads, a video's narration or an article read aloud are never the streamer; when they react to an article or a video, its subject is the topic and their reaction is the take. A streamer who talks in one language most of the time does not break into short theatrical one-liners in another: dramatic lines in a different language (often English) dropped between their own talk, like \"A great queen needs tenacity.\" or \"Fear what I'll do now.\", are the game's characters, not roleplay or the streamer, so they are never a segment, a take or a quote. In a game stream, short lines in a character's voice (boasts, threats, catchphrases, solemn or poetic lines such as \"Kneel before me or bleed out.\", \"No one can find the queen!\", \"Only those you love can break your heart.\" or \"I would cut off my wings if there were steel strong enough.\") are the game even in English: the streamer is not roleplaying or being poetic. A sports game's commentators (\"We present…\", play-by-play, player names and tactics read out) are the game too. Lines that do not make sense (\"GCC embedded diablo, but controlling topic albanese\") are mistranscriptions: skip them and never invent a meaning for them. (2) Split the stretch into segments, one per thing they talked about, at most 4, in order. Skip stretches that are only gameplay noise, mumbling or garbled text; an empty list is a good answer when nothing was said. Play-by-play is not a segment: reacting to kills, deaths, teammates, plays, rounds or a fight as it happens is the game itself, and the stream's recap covers it. A segment about a game needs something a reader could follow without watching: an opinion (a champion is broken, matchmaking is rigged), a story, a plan, a running bit, an argument. A stretch that is only play-by-play gets at most one segment, and only for a standout moment. Never make a segment that is more of what Earlier in this stream already covered. (3) topic: a short concrete name for the subject, the way a headline names it (\"Supermarket fridges hacked in France\", \"League ranked matchmaking\", \"Moving apartments\"), never vague (\"news\", \"gaming\", \"chatting\", \"the stream\"), never one of the kinds below (\"chat\", \"games\", \"life\"…) and never a mood or a description of the stream (\"Frustration and chaos in the match\", \"Talking with chat and teammates\", \"Mid-game rage\"): name the subject, for a game the game and the thing (\"Briar in League\", \"League autofill\", \"Minecraft base building\"), for something they watched what it was (\"Boxing fight night\"); when nothing more specific fits, name what they were doing (\"Theme park rides\"). When it is the same specific subject as one of the known topics, return that name exactly; never file a segment under a known topic only because it is the same game or the same mood. (4) kind: news, games, tech, life, music, sports, money, internet, chat (talking with chat about the stream itself) or other. (5) title: a punchy headline for the segment, at most 70 characters. what: what they talked about, plainly, one or two sentences. take: their opinion or stance in one sentence, or null when they had none. quip: one line from the desk about the take or the situation, dry and a little savage, the way a late-night host would rib it, at most 160 characters. score: how much a reader who missed the stream would enjoy reading it, 1 (filler) to 5 (the best thing said all night): play-by-play and in-game reactions are 2 at most unless something standout happened, most segments are a 3, and a 4 or 5 needs a real opinion, story or line. (6) quotes: 0 to 2 lines of the streamer's own words, copied character for character from the transcript lines (part of one line is fine), the funniest or most telling, each with its t. A quote is something they said themselves: never a sentence they read out from an article, a post, a tweet, a chat message or a video, even when it is in quotation marks in the source; when unsure, leave it out. When a quote is not in English, en is an English translation; otherwise en is null. (7) start and end are seconds from the lines. Write topic, title, what, take and quip in English whatever language they speak. In what and take, call the streamer by the name given in Streamer, never \"the streamer\" or \"Streamer\". (8) Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",user_prompt: "Streamer: {{streamer}}\nStream title: {{title}}\nCategory: {{category}}\nKnown topics (reuse the exact name for the same subject):\n{{topics}}\nEarlier in this stream: {{earlier}}\n\nTranscript lines ([seconds into the stream] text):\n{{lines}}",default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_segment' },},@@@ -129,7 +129,7 @@ const templates = [description: "From rundown/desk.js: a finished stream's facts and segments in; an episode title, logline, recap and chapters out.",input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "logline", "recap", "chapters"], "properties": {"title": {"type": "string"}, "logline": {"type": "string"}, "recap": {"type": "string"}, "chapters": {"type": "array", "maxItems": 10, "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer", "minimum": 0}, "title": {"type": "string"}}}}}},-- system_prompt: "You write the episode guide of THE RUNDOWN, where every live stream is an episode. You get the stream's facts and its segments, what the streamer talked about, in order. Write: title: the episode's title, at most 60 characters, playful and specific to this stream (never just the streamer's name or \"Stream\"). logline: one sentence of at most 180 characters, the hook that makes someone open it. recap: the story of the stream in order, two or three short paragraphs (blank line between them), at most 900 characters in all, with personality: dry, teasing, roasting the takes and the situations, never mean about the person. Quote only from the quotes given, word for word. chapters: up to 8 segments worth listing, each the segment's index and a short chapter title of at most 50 characters. Copy numbers exactly. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",++ system_prompt: "You write the episode guide of THE RUNDOWN, where every live stream is an episode. You get the stream's facts and its segments, what the streamer talked about, in order. Write: title: the episode's title, at most 60 characters, playful and specific to this stream (never just the streamer's name or \"Stream\"). logline: one sentence of at most 180 characters, the hook that makes someone open it. recap: the story of the stream in order, two or three short paragraphs (blank line between them), at most 900 characters in all, with personality: dry, teasing, roasting the takes and the situations, never mean about the person. Quote only from the quotes given, word for word. chapters: up to 8 segments worth listing, each the segment's index and a short chapter title of at most 50 characters. Copy numbers exactly. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",user_prompt: "{{json payload}}",default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_episode' },},@@@ -138,7 +138,7 @@ const templates = [description: "From rundown/desk.js: one topic and every take on it in; a headline and a summary out.",input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "summary"], "properties": {"headline": {"type": "string"}, "summary": {"type": "string"}}},-- system_prompt: "You run THE RUNDOWN's topic pages. You get one topic and the takes streamers had on it, oldest first. Write: headline: at most 80 characters, punchy, about the topic and the takes. summary: two to four sentences, at most 600 characters: what the subject is, who said what, where they agree or clash, and one dry line from the desk. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",++ system_prompt: "You run THE RUNDOWN's topic pages. You get one topic and the takes streamers had on it, oldest first. Write: headline: at most 80 characters, punchy, about the topic and the takes. summary: two to four sentences, at most 600 characters: what the subject is, who said what, where they agree or clash, and one dry line from the desk. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",user_prompt: "{{json payload}}",default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },},@@@ -175,7 +175,7 @@ const templates = [input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},system_prompt: "",-- user_prompt: "You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Never describe their looks, body, clothing or age, and use their name or \"they\", never a guessed pronoun. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",++ user_prompt: "You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Use their name or \"they\", never a guessed pronoun. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },},{@@@ -184,7 +184,7 @@ const templates = [input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},system_prompt: "",-- user_prompt: "Write a SHORT, catchy stream title for each livestream summary below — the way a real streamer titles a VOD: 3 to 6 words, punchy, no surrounding quotes, no trailing period. Match each stream's vibe. Name what happens on the stream (the game, the activity, the topic), never what anyone looks like or wears (no shirts, hoodies, beanies, hats, glasses, headsets, beards, hair or faces).\n\n{{list}}\n\nReturn STRICT JSON only: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",++ user_prompt: "Write a SHORT, catchy stream title for each livestream summary below — the way a real streamer titles a VOD: 3 to 6 words, punchy, no surrounding quotes, no trailing period. Match each stream's vibe. Name what happens on the stream (the game, the activity, the topic), people included.\n\n{{list}}\n\nReturn STRICT JSON only: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'session_titles' },},{@@@ -202,7 +202,7 @@ const templates = [input_schema: { type: 'object' },output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},system_prompt: '',-- user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone. Never mention anyone's looks, body, clothing or age (no \"shirtless\", no \"young\"): talk about what they stream and say. This is the first line of the front page: nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes (\"qweqwe\").\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",++ user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone. Talk about what they stream and say. This is the first line of the front page: nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes (\"qweqwe\").\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },},{@@@ -235,7 +235,7 @@ const templates = [},},system_prompt: '',-- user_prompt: 'Below is a single livestream VOD titled "{{title}}", described by its on-screen TIMELINE (visual scene notes) and its AUDIO TRANSCRIPT, each line timestamped [m:ss]. Titles and descriptions are about what happens, never about what people look like (body, clothing, age, ethnicity or perceived gender).\n\nTIMELINE:\n{{timeline}}\n\nTRANSCRIPT:\n{{transcript}}\n\nSOUNDS HEARD (non-speech audio events):\n{{sounds}}\n\nViewers CLIPPED these timestamps (very strong "this was a highlight" signal): {{clipped}}\nChat activity SPIKED around: {{spikes}}\n\n{{flavor_hint}}\n{{#avoid}}ALREADY USED (a paste or clip exists there) — do NOT pick these or anything within 2 minutes of them: {{avoid}}. Find a DIFFERENT moment.{{/avoid}}\n\nFind the SINGLE most interesting/funny/dramatic/surprising/striking moment in this VOD with something clearly VISIBLE happening. Prefer moments backed by the clip/chat signals when they line up with something notable. NEVER pick a black/dark/loading/blank screen, an intro/BRB card, or a moment with no visible content or activity. Return STRICT JSON only, nothing else: {"t": <seconds into the vod>, "title": "<specific punchy 3-8 word title, not the stream name, funny, never generic>", "desc": "<one vivid sentence describing the moment>"}',++ user_prompt: 'Below is a single livestream VOD titled "{{title}}", described by its on-screen TIMELINE (visual scene notes) and its AUDIO TRANSCRIPT, each line timestamped [m:ss]. Titles and descriptions are about what happens.\n\nTIMELINE:\n{{timeline}}\n\nTRANSCRIPT:\n{{transcript}}\n\nSOUNDS HEARD (non-speech audio events):\n{{sounds}}\n\nViewers CLIPPED these timestamps (very strong "this was a highlight" signal): {{clipped}}\nChat activity SPIKED around: {{spikes}}\n\n{{flavor_hint}}\n{{#avoid}}ALREADY USED (a paste or clip exists there) — do NOT pick these or anything within 2 minutes of them: {{avoid}}. Find a DIFFERENT moment.{{/avoid}}\n\nFind the SINGLE most interesting/funny/dramatic/surprising/striking moment in this VOD with something clearly VISIBLE happening. Prefer moments backed by the clip/chat signals when they line up with something notable. NEVER pick a black/dark/loading/blank screen, an intro/BRB card, or a moment with no visible content or activity. Return STRICT JSON only, nothing else: {"t": <seconds into the vod>, "title": "<specific punchy 3-8 word title, not the stream name, funny, never generic>", "desc": "<one vivid sentence describing the moment>"}',default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_pick' },},{@@@ -251,7 +251,7 @@ const templates = [grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },},},-- system_prompt: 'You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Never describe anyone\'s looks, body, clothing or age. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.',++ system_prompt: 'You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.',user_prompt: '{{json facts}}',default_route: 'live.summary', owner: 'live', metadata: { json_name: 'stream_recap' },},@@@ -35,7 +35,7 @@ function productTemplate(key, name, task, route, extraUser = '') {const templates = [productTemplate('wiki.generate_space', 'Wiki: plan a wiki', 'Plan a wiki about the topic, written like an encyclopedia or a well-kept fan wiki, from the numbered sources. `title`: the wiki\'s name (usually the topic\'s name). `summary`: one paragraph saying what the topic is, cited. `pages`: the main page first, titled exactly as the topic is best known, with kind overview; then the subjects a reader would look up, as many as the sources say enough about, up to {{max_pages}} pages in all: the people or characters, works (episodes, seasons, films, books, games, albums), places, groups, objects, concepts, events, and its production and reception when the sources cover them. Each page: `title` (its usual name; add the topic in brackets only to tell two subjects apart), `kind`, `parent` (the title of another page in this list it belongs under, else an empty string; the main page has none), `outline` (three to six short section headings this page should have), `query` (a short web search phrase that finds this subject, such as "Ron Stoppable Kim Possible"), and `citations`. Only plan subjects the sources name; leave out anything they do not support.', 'wiki.write','Topic: {{topic}}{{#audience}}\nAudience: {{audience}}{{/audience}}\nPages at most: {{max_pages}}\n\n'),-- productTemplate('wiki.generate_page', 'Wiki: write a page', 'Write one page of the wiki from the numbered sources, encyclopedic and engaging, the way a well-kept wiki reads. `summary`: one or two plain sentences saying what the subject is. `lead`: the opening paragraph or two, in Markdown. `sections`: follow the requested outline where the sources support it (skip or merge headings they do not), each with a `heading` and a Markdown `body` of one or more full paragraphs (a list where it reads better), each citing its sources; about 500 to 1500 words in all when the sources allow, shorter when they do not. Link other pages of this wiki, and only titles from the list of pages, as [[Exact Title]] the first time each is mentioned. `infobox`: up to twelve key facts as `label` and `value` (for example First appearance, Voiced by, Created by, Release date, Location), each cited; the Wikidata facts are good material. `images`: up to three pictures from the numbered list of pictures that clearly show this page\'s subject, judged by their title and description (never a picture of something or someone else, and none when no picture fits), each with its `index`, a short factual `caption`, and `section`: the heading it illustrates, or lead for the page\'s main picture. Never write source numbers or "[n]" in the text. Never describe a real person\'s looks, body, clothing or age.', 'wiki.write',++ productTemplate('wiki.generate_page', 'Wiki: write a page', 'Write one page of the wiki from the numbered sources, encyclopedic and engaging, the way a well-kept wiki reads. `summary`: one or two plain sentences saying what the subject is. `lead`: the opening paragraph or two, in Markdown. `sections`: follow the requested outline where the sources support it (skip or merge headings they do not), each with a `heading` and a Markdown `body` of one or more full paragraphs (a list where it reads better), each citing its sources; about 500 to 1500 words in all when the sources allow, shorter when they do not. Link other pages of this wiki, and only titles from the list of pages, as [[Exact Title]] the first time each is mentioned. `infobox`: up to twelve key facts as `label` and `value` (for example First appearance, Voiced by, Created by, Release date, Location), each cited; the Wikidata facts are good material. `images`: up to three pictures from the numbered list of pictures that clearly show this page\'s subject, judged by their title and description (never a picture of something or someone else, and none when no picture fits), each with its `index`, a short factual `caption`, and `section`: the heading it illustrates, or lead for the page\'s main picture. Never write source numbers or "[n]" in the text.', 'wiki.write','Page title: {{title}}\nWiki topic: {{topic}}{{#outline}}\nRequested outline: {{json outline}}{{/outline}}\n\nPages of this wiki (link only these, as [[Title]]):\n{{pages_block}}\n\n{{#images_block}}Pictures (by number):\n{{images_block}}\n\n{{/images_block}}'),productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'blog.draft','Topic: {{topic}}{{#brief}}\nBrief: {{brief}}{{/brief}}{{#tone}}\nTone: {{tone}}{{/tone}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),@@@ -265,7 +265,7 @@ templates.push({key: 'pics.describe_image', name: 'Pics: describe a picture',description: 'Alt text, a short caption and tags for one picture on OpenVibe.Pics.',input_schema: { type: 'object' }, output_schema: { type: 'object' },-- system_prompt: 'You describe one picture for OpenVibe.Pics so people who cannot see it know what it shows. alt: one plain sentence of at most 140 characters saying what the picture shows (never "image of" or "picture of"). caption: one or two sentences with the detail a sighted person would notice first. tags: up to six short lowercase words for what is in it. Describe only what is visible. Never name or guess who a real person is, and never read out personal details shown in it (names, addresses, number plates, phone numbers, faces of children described by age or appearance beyond "a child"). If it shows text, say what kind of text it is rather than quoting personal parts. Output only JSON matching the schema.',++ system_prompt: 'You describe one picture for OpenVibe.Pics so people who cannot see it know what it shows. alt: one plain sentence of at most 140 characters saying what the picture shows (never "image of" or "picture of"). caption: one or two sentences with the detail a sighted person would notice first. tags: up to six short lowercase words for what is in it. Describe only what is visible. Never name or guess who a real person is, and never read out personal details shown in it (names, addresses, number plates, phone numbers). If it shows text, say what kind of text it is rather than quoting personal parts. Output only JSON matching the schema.',user_prompt: '{{#title}}The uploader titled it: {{title}}\n{{/title}}Describe the picture.',default_route: 'pics.vision', owner: 'media-hub', metadata: { json_name: 'pics_describe_image' },});@@@ -108,27 +108,20 @@ t.test('the AI viewers render as Live rendered them', () => {assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);});--t.test("a channel description is public prose: never about looks, no lists or labels reach the page", () => {++t.test("AI text may describe people: no looks ban in the public prose prompts", () => {const tp = (key) => live.templates.find((x) => x.key === key);const prompt = tp('live.streamer.overview').user_prompt;assert.match(prompt, /public description/);-- assert.match(prompt, /Never describe anyone's appearance, body, clothing, age/);++ assert.doesNotMatch(prompt, /Never describe anyone's appearance|never about what people look like/);assert.match(prompt, /never guess pronouns/);-- assert.ok(!/moderation\.?$|notable for moderation/m.test(prompt), 'no staff-only moderation notes');-- assert.match(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);-- // Frame notes are public (a VOD page's timeline) and feed every summary: none describes how people look.-- assert.match(tp('live.stream.describe_frame').user_prompt, /never mention anyone's body, clothing, age/);-- assert.match(tp('live.stream.summarize').user_prompt, /never about what people look like/);-- assert.match(tp('live.moments.pick').user_prompt, /never about what people look like/);-- assert.match(tp('live.stream.recap').system_prompt, /Never describe anyone's looks/);-- assert.match(tp('live.hero.slogans').user_prompt, /Never mention anyone's looks, body, clothing or age/);-- assert.match(tp('live.hero.slogans').user_prompt, /nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes/);-- assert.match(tp('live.stream.describe_frame').user_prompt, /caption naming what happens, never what someone looks like or wears/);-- assert.match(tp('live.stream.titles').user_prompt, /never what anyone looks like or wears/);-- const out = POSTPROCESS['live.streamer.overview']({ text: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios\n- Tone: calm, patient\n\nAnn streams games: mostly Rust.' });-- assert.deepStrictEqual(out, { overview: 'finditfixit (@finditfixit). repairs old TVs and radios. calm, patient. Ann streams games: mostly Rust.' });-- assert.strictEqual(POSTPROCESS['live.streamer.overview']({ text: '## Overview\n' }), null, 'nothing but a heading is no overview');-- assert.deepStrictEqual(POSTPROCESS['live.media.overview']({ text: 'They build a PC.\nThen they test it' }), { overview: 'They build a PC. Then they test it.' });++ assert.doesNotMatch(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);++ assert.doesNotMatch(tp('live.stream.describe_frame').user_prompt, /never mention anyone's body/);++ assert.doesNotMatch(tp('live.stream.summarize').user_prompt, /never about what people look like/);++ assert.doesNotMatch(tp('live.moments.pick').user_prompt, /never about what people look like/);++ assert.doesNotMatch(tp('live.stream.recap').system_prompt, /Never describe anyone's looks/);++ assert.doesNotMatch(tp('live.hero.slogans').user_prompt, /Never mention anyone's looks/);++ assert.match(tp('live.hero.slogans').user_prompt, /nothing crude or sexual/);++ assert.doesNotMatch(tp('live.stream.titles').user_prompt, /never what anyone looks like or wears/);});t.test('The Rundown: the segment prompt lists the lines and known topics; its output is clamped, kept to the lines and stripped of empty segments', () => {@@@ -216,7 +216,7 @@ t.test('a conflict is marked, hidden from an agent, and the person settles it',} finally { await h.stop(); await seam.close(); }});--t.test('the profile is built from at most 60 notes, cached by items_hash, once an hour, and passes noLooks', async () => {++t.test('the profile is built from at most 60 notes, cached by items_hash, once an hour, and is stored as written', async () => {let now = Date.now();const clock = { now: () => now };const { seam, state } = await startSeam();@@@ -236,7 +236,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once aconst first = await request(h.base, 'GET', '/api/v1/memory/profile?scope=user', { tok: who });assert.equal(first.status, 200, `profile: ${first.text}`);const text = first.body.profile.text;-- assert.ok(!text.includes('shirtless') && !text.includes('man'), `noLooks did not run: ${text}`);++ assert.ok(text.includes('shirtless') && text.includes('man'), `the profile is stored as written: ${text}`);assert.ok(text.length <= 4000, 'the profile is at most about a thousand tokens');// The prompt carries the person's own notes only, and at most 60 of them, newest first.@@@ -268,7 +268,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once aassert.equal(state.chats, 2, 'the profile was rebuilt after the hour');assert.notEqual(rebuilt.body.profile.built_at, first.body.profile.built_at);assert.notEqual(rebuilt.body.profile.items_hash, first.body.profile.items_hash);-- assert.ok(!rebuilt.body.profile.text.includes('shirtless'), 'the rebuilt profile passes noLooks too');++ assert.ok(rebuilt.body.profile.text.includes('shirtless'), 'the rebuilt profile is stored as written too');} finally { await h.stop(); await seam.close(); }});t.test('the job walks owner by owner, within its caps', async () => {@@@ -328,7 +328,7 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth} finally { await h.stop(); }});--t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and passes noLooks', async () => {++t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and is stored as written', async () => {const h = await boot({ env: { AI_MEMORY_KEY: KEY } });try {let now = Date.parse('2026-10-30T09:00:00.000Z');@@@ -340,7 +340,7 @@ t.test('the profile is cached, rebuilt only when items_hash moves and at most onawait m.batch({ principal: req.principal, body: { items: [item('one note'), item('two notes')] } }, owner);const first = await m.profile(req, owner);assert.equal(calls, 1);-- assert.ok(!first.profile.text.includes('shirtless') && !first.profile.text.includes('man'), `noLooks did not run: ${first.profile.text}`);++ assert.ok(first.profile.text.includes('shirtless') && first.profile.text.includes('man'), `the profile is stored as written: ${first.profile.text}`);assert.equal(first.profile.text.includes('[redacted]'), false);const second = await m.profile(req, owner);assert.equal(calls, 1, 'the same items_hash is served from the cache');nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old++// looks filter again.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) walk(p);++ else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));++ }++})(path.join(root, 'server'));++assert.deepStrictEqual(offenders, [], 'no looks filter in server/');++console.log('No looks filter: AI text describes people as written.');
@@ -37,7 +37,7 @@
@@ -37,7 +37,7 @@
* - Two active items that are near each other but disagree (cosine 0.75 to 0.92) are judged by one cheap-tier
* - Two active items that are near each other but disagree (cosine 0.75 to 0.92) are judged by one cheap-tier
* call that sees only those two notes, and both are marked `status = 'conflict'` with a `conflict_with` link:
* call that sees only those two notes, and both are marked `status = 'conflict'` with a `conflict_with` link:
* a conflict never reaches an agent. The person settles it (POST /items/:id/settle { keep }).
* a conflict never reaches an agent. The person settles it (POST /items/:id/settle { keep }).
- * - The profile is built from at most 60 active notes, newest first, passes noLooks and is at most about a
+ * - The profile is built from at most 60 active notes, newest first, is stored as written and is at most about a
* thousand tokens. It is cached by items_hash and rebuilt at most once an hour per owner and scope.
* thousand tokens. It is cached by items_hash and rebuilt at most once an hour per owner and scope.
* - The store's own maintenance runs in the background (server/memory/jobs.js): owner by owner, with a per-run
* - The store's own maintenance runs in the background (server/memory/jobs.js): owner by owner, with a per-run
* cap, off in test mode and off in a drill.
* cap, off in test mode and off in a drill.
@@ -47,7 +47,6 @@ const express = require('express');
@@ -47,7 +47,6 @@ const express = require('express');
const { ids, http, capabilities } = require('openvibe-contracts');
const { ids, http, capabilities } = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { redact, onlySecret } = require('openvibe-sdk/redact');
const { redact, onlySecret } = require('openvibe-sdk/redact');
-const { noLooks } = require('openvibe-sdk/no-looks');
const { AiError, iso, sha256 } = require('../util');
const { AiError, iso, sha256 } = require('../util');
const { bearer, isAppClaims, principalSubject } = require('../auth');
const { bearer, isAppClaims, principalSubject } = require('../auth');
@@ -56,7 +55,7 @@ const SCOPE_RE = /^(user|project:[a-z0-9][a-z0-9._-]{0,63})$/;
@@ -56,7 +55,7 @@ const SCOPE_RE = /^(user|project:[a-z0-9][a-z0-9._-]{0,63})$/;
const SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
const SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];
const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];
const STATUSES = ['active', 'suggested', 'archived', 'conflict'];
const STATUSES = ['active', 'suggested', 'archived', 'conflict'];
-/** Items from these sources were produced by an AI, so they are suggestions and pass noLooks. */
+/** Items from these sources were produced by an AI, so they are suggestions and are stored as written. */
const AI_SOURCES = new Set(['actor', 'vibe', 'know']);
const AI_SOURCES = new Set(['actor', 'vibe', 'know']);
const PROFILE_INTERVAL_MS = 60 * 60 * 1000;
const PROFILE_INTERVAL_MS = 60 * 60 * 1000;
/* M2c: the meaning of an item. Two items of one owner and scope that say the same thing (cosine at least
/* M2c: the meaning of an item. Two items of one owner and scope that say the same thing (cosine at least
@@ -469,12 +468,12 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -469,12 +468,12 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
? req.principal.subject.id
? req.principal.subject.id
: String(req.principal.sub || '').replace(/^svc:/, '');
: String(req.principal.sub || '').replace(/^svc:/, '');
const source = validateSource(serviceCaller ? serviceSource : (raw.source || 'person'));
const source = validateSource(serviceCaller ? serviceSource : (raw.source || 'person'));
- // A service is an agent: what it writes is a suggestion, and it passes noLooks. A person may mark their own
- // item as AI-produced too (the Node imports what a coding tool wrote).
+ // A service is an agent: what it writes is a suggestion, stored as written. A person may mark their own item
+ // as AI-produced too (the Node imports what a coding tool wrote).
const aiMade = serviceCaller || raw.no_looks === true || AI_SOURCES.has(source);
const aiMade = serviceCaller || raw.no_looks === true || AI_SOURCES.has(source);
- let text = aiMade ? noLooks(clean) : clean;
+ let text = clean;
text = String(text == null ? '' : text);
text = String(text == null ? '' : text);
- if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty after no-looks`);
+ if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty`);
if (onlySecret(text)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);
if (onlySecret(text)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);
const askedConfidence = raw.confidence == null ? null : Number(raw.confidence);
const askedConfidence = raw.confidence == null ? null : Number(raw.confidence);
const confidence = askedConfidence == null || !Number.isFinite(askedConfidence)
const confidence = askedConfidence == null || !Number.isFinite(askedConfidence)
@@ -589,7 +588,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -589,7 +588,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
const clean = redact(String(body.text)).text;
const clean = redact(String(body.text)).text;
if (!clean.trim()) throw new AiError(422, 'input.invalid', 'text is empty');
if (!clean.trim()) throw new AiError(422, 'input.invalid', 'text is empty');
if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
- nextText = body.no_looks === true ? String(noLooks(clean)) : clean;
+ nextText = clean;
if (onlySecret(nextText)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
if (onlySecret(nextText)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
add('text_enc', seal(nextText, aadFor(owner, row.id, nextScope)));
add('text_enc', seal(nextText, aadFor(owner, row.id, nextScope)));
add('text_mac', macOf(nextText));
add('text_mac', macOf(nextText));
@@ -670,7 +669,6 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -670,7 +669,6 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
const prompt = [
const prompt = [
`Write a short profile of the person from their own memory notes for ${scope}.`,
`Write a short profile of the person from their own memory notes for ${scope}.`,
'Rules: summarize only what the notes say. Never guess or infer facts about the person.',
'Rules: summarize only what the notes say. Never guess or infer facts about the person.',
- 'Never describe looks, body, clothing, hair, face, pose, age or a guessed gender: no looks, at any time.',
'Write at most 600 words and do not add a title.',
'Write at most 600 words and do not add a title.',
'',
'',
notes,
notes,
@@ -682,7 +680,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -682,7 +680,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
const route = await registry.resolveRoute('default.chat');
const route = await registry.resolveRoute('default.chat');
if (!route || route.disabled) throw new AiError(503, 'memory.profile_failed', 'no route is available to build a profile');
if (!route || route.disabled) throw new AiError(503, 'memory.profile_failed', 'no route is available to build a profile');
const exec = await pool.execute(route, 'summarize', {
const exec = await pool.execute(route, 'summarize', {
- system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess, never describe looks or a body, and never add a fact the notes do not state.', cache: true }],
+ system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess and never add a fact the notes do not state.', cache: true }],
messages: [{ role: 'user', content: prompt }],
messages: [{ role: 'user', content: prompt }],
maxTokens: PROFILE_MAX_TOKENS,
maxTokens: PROFILE_MAX_TOKENS,
temperature: 0.2,
temperature: 0.2,
@@ -718,7 +716,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -718,7 +716,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
return { profile: { scope, text: '', items_hash: hash, built_at: builtAt }, rebuilt: true };
return { profile: { scope, text: '', items_hash: hash, built_at: builtAt }, rebuilt: true };
}
}
const items = rows.map((r) => ({ kind: r.kind, text: open(r.text_enc, aadFor(owner, r.id, r.scope)) }));
const items = rows.map((r) => ({ kind: r.kind, text: open(r.text_enc, aadFor(owner, r.id, r.scope)) }));
- const text = String(noLooks(await summarize({ owner, scope, items })) || '').slice(0, PROFILE_MAX_CHARS);
+ const text = String((await summarize({ owner, scope, items })) || '').slice(0, PROFILE_MAX_CHARS);
const builtAt = iso(now);
const builtAt = iso(now);
await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)
await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)
ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)
@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {
@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {
});
});
route('get', '/memory/profile', {
route('get', '/memory/profile', {
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
- description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result passes noLooks.',
+ description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',
parameters: [query('scope', str()), query('on_behalf_of', str())],
parameters: [query('scope', str()), query('on_behalf_of', str())],
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
});
});
@@ -52,7 +52,7 @@ const templates = [
@@ -52,7 +52,7 @@ const templates = [
description: 'From ai-analysis.analyzeStreamFrame (stream_memory): memory description, tags, and a screenshot-worthiness verdict + caption for live pastes, in one vision call.',
description: 'From ai-analysis.analyzeStreamFrame (stream_memory): memory description, tags, and a screenshot-worthiness verdict + caption for live pastes, in one vision call.',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'This is a frame from a live stream. Describe what is happening (the game, the activity, the scene, text on screen), not what people look like: never mention anyone\'s body, clothing, age, ethnicity or perceived gender, and call a person "the streamer". Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a big reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption naming what happens, never what someone looks like or wears (no hats, beanies, shirts, glasses, faces or hair), else empty>"}.',
+ user_prompt: 'This is a frame from a live stream. Describe what is happening (the game, the activity, the scene, text on screen), people included, and call a person "the streamer". Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a big reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption naming what happens , people included, else empty>"}.',
default_route: 'live.vision', owner: 'live',
default_route: 'live.vision', owner: 'live',
},
},
{
{
@@ -60,7 +60,7 @@ const templates = [
@@ -60,7 +60,7 @@ const templates = [
description: 'From ai-analysis.summarizeStreamMemories: whole-session overview from timestamped observations and the audio timeline; category is inferred, never defaulted.',
description: 'From ai-analysis.summarizeStreamMemories: whole-session overview from timestamped observations and the audio timeline; category is inferred, never defaulted.',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'These are timestamped observations from a live stream, in order since it started. Write about the activities, topics and vibe, never about what people look like (body, clothing, age, ethnicity or perceived gender); call a person "the streamer". Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',
+ user_prompt: 'These are timestamped observations from a live stream, in order since it started. Write about the activities, topics and vibe, people included; call a person "the streamer". Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
@@ -68,7 +68,7 @@ const templates = [
@@ -68,7 +68,7 @@ const templates = [
description: 'From ai-analysis.generateStreamerOverview.',
description: 'From ai-analysis.generateStreamerOverview.',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name, and after that refer to them by name or as "they"; never guess pronouns.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender, and never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',
+ user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name, and after that refer to them by name or as "they"; never guess pronouns.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
@@ -76,7 +76,7 @@ const templates = [
@@ -76,7 +76,7 @@ const templates = [
description: 'From media-analysis.analyzeMedia (media_overview).',
description: 'From media-analysis.analyzeMedia (media_overview).',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown. Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender.\n\n{{parts}}',
+ user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown.\n\n{{parts}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
@@ -120,7 +120,7 @@ const templates = [
@@ -120,7 +120,7 @@ const templates = [
description: "From rundown/desk.js: a stretch of one stream's speech-to-text lines in; the segments of what the streamer talked about out (topic, headline, what, take, the desk's quip, verbatim quotes).",
description: "From rundown/desk.js: a stretch of one stream's speech-to-text lines in; the segments of what the streamer talked about out (topic, headline, what, take, the desk's quip, verbatim quotes).",
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["others", "segments"], "properties": {"others": {"type": "array", "maxItems": 400, "items": {"type": "number"}}, "segments": {"type": "array", "maxItems": 4, "items": {"type": "object", "additionalProperties": false, "required": ["start", "end", "topic", "kind", "title", "what", "take", "quip", "score", "quotes"], "properties": {"start": {"type": "number"}, "end": {"type": "number"}, "topic": {"type": "string"}, "kind": {"type": "string", "enum": ["news", "games", "tech", "life", "music", "sports", "money", "internet", "chat", "other"]}, "title": {"type": "string"}, "what": {"type": "string"}, "take": {"type": ["string", "null"]}, "quip": {"type": "string"}, "score": {"type": "integer", "minimum": 1, "maximum": 5}, "quotes": {"type": "array", "maxItems": 2, "items": {"type": "object", "additionalProperties": false, "required": ["t", "text", "en"], "properties": {"t": {"type": "number"}, "text": {"type": "string"}, "en": {"type": ["string", "null"]}}}}}}}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["others", "segments"], "properties": {"others": {"type": "array", "maxItems": 400, "items": {"type": "number"}}, "segments": {"type": "array", "maxItems": 4, "items": {"type": "object", "additionalProperties": false, "required": ["start", "end", "topic", "kind", "title", "what", "take", "quip", "score", "quotes"], "properties": {"start": {"type": "number"}, "end": {"type": "number"}, "topic": {"type": "string"}, "kind": {"type": "string", "enum": ["news", "games", "tech", "life", "music", "sports", "money", "internet", "chat", "other"]}, "title": {"type": "string"}, "what": {"type": "string"}, "take": {"type": ["string", "null"]}, "quip": {"type": "string"}, "score": {"type": "integer", "minimum": 1, "maximum": 5}, "quotes": {"type": "array", "maxItems": 2, "items": {"type": "object", "additionalProperties": false, "required": ["t", "text", "en"], "properties": {"t": {"type": "number"}, "text": {"type": "string"}, "en": {"type": ["string", "null"]}}}}}}}}},
- system_prompt: "You are the writers' room of THE RUNDOWN, the part of a small live-streaming site that turns what streamers say on stream into stories people want to read. You get an automatic transcript of a stretch of one live stream and pull out what the streamer actually talked about. The transcript is speech recognition of the stream's whole audio: it mixes the streamer's own voice with game sounds, announcers and characters, videos and songs they play, articles they read aloud and other people, and it mishears words and sometimes the language. Rules: (0) First decide who is speaking on every line, and list in others the t of every line that is NOT the streamer (a game's announcer or characters, a video or clip, a song, an article read aloud, other people); build segments and pick quotes only from the streamer's own lines. (1) Only the streamer's own talk is theirs: their opinions, stories, rants, jokes, plans, reactions and conversations with chat. A game's announcer or character lines (\"You have slain an enemy\", \"First blood\", \"Victory\"), lyrics, ads, a video's narration or an article read aloud are never the streamer; when they react to an article or a video, its subject is the topic and their reaction is the take. A streamer who talks in one language most of the time does not break into short theatrical one-liners in another: dramatic lines in a different language (often English) dropped between their own talk, like \"A great queen needs tenacity.\" or \"Fear what I'll do now.\", are the game's characters, not roleplay or the streamer, so they are never a segment, a take or a quote. In a game stream, short lines in a character's voice (boasts, threats, catchphrases, solemn or poetic lines such as \"Kneel before me or bleed out.\", \"No one can find the queen!\", \"Only those you love can break your heart.\" or \"I would cut off my wings if there were steel strong enough.\") are the game even in English: the streamer is not roleplaying or being poetic. A sports game's commentators (\"We present…\", play-by-play, player names and tactics read out) are the game too. Lines that do not make sense (\"GCC embedded diablo, but controlling topic albanese\") are mistranscriptions: skip them and never invent a meaning for them. (2) Split the stretch into segments, one per thing they talked about, at most 4, in order. Skip stretches that are only gameplay noise, mumbling or garbled text; an empty list is a good answer when nothing was said. Play-by-play is not a segment: reacting to kills, deaths, teammates, plays, rounds or a fight as it happens is the game itself, and the stream's recap covers it. A segment about a game needs something a reader could follow without watching: an opinion (a champion is broken, matchmaking is rigged), a story, a plan, a running bit, an argument. A stretch that is only play-by-play gets at most one segment, and only for a standout moment. Never make a segment that is more of what Earlier in this stream already covered. (3) topic: a short concrete name for the subject, the way a headline names it (\"Supermarket fridges hacked in France\", \"League ranked matchmaking\", \"Moving apartments\"), never vague (\"news\", \"gaming\", \"chatting\", \"the stream\"), never one of the kinds below (\"chat\", \"games\", \"life\"…) and never a mood or a description of the stream (\"Frustration and chaos in the match\", \"Talking with chat and teammates\", \"Mid-game rage\"): name the subject, for a game the game and the thing (\"Briar in League\", \"League autofill\", \"Minecraft base building\"), for something they watched what it was (\"Boxing fight night\"); when nothing more specific fits, name what they were doing (\"Theme park rides\"). When it is the same specific subject as one of the known topics, return that name exactly; never file a segment under a known topic only because it is the same game or the same mood. (4) kind: news, games, tech, life, music, sports, money, internet, chat (talking with chat about the stream itself) or other. (5) title: a punchy headline for the segment, at most 70 characters. what: what they talked about, plainly, one or two sentences. take: their opinion or stance in one sentence, or null when they had none. quip: one line from the desk about the take or the situation, dry and a little savage, the way a late-night host would rib it, at most 160 characters. score: how much a reader who missed the stream would enjoy reading it, 1 (filler) to 5 (the best thing said all night): play-by-play and in-game reactions are 2 at most unless something standout happened, most segments are a 3, and a 4 or 5 needs a real opinion, story or line. (6) quotes: 0 to 2 lines of the streamer's own words, copied character for character from the transcript lines (part of one line is fine), the funniest or most telling, each with its t. A quote is something they said themselves: never a sentence they read out from an article, a post, a tweet, a chat message or a video, even when it is in quotation marks in the source; when unsure, leave it out. When a quote is not in English, en is an English translation; otherwise en is null. (7) start and end are seconds from the lines. Write topic, title, what, take and quip in English whatever language they speak. In what and take, call the streamer by the name given in Streamer, never \"the streamer\" or \"Streamer\". (8) Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
+ system_prompt: "You are the writers' room of THE RUNDOWN, the part of a small live-streaming site that turns what streamers say on stream into stories people want to read. You get an automatic transcript of a stretch of one live stream and pull out what the streamer actually talked about. The transcript is speech recognition of the stream's whole audio: it mixes the streamer's own voice with game sounds, announcers and characters, videos and songs they play, articles they read aloud and other people, and it mishears words and sometimes the language. Rules: (0) First decide who is speaking on every line, and list in others the t of every line that is NOT the streamer (a game's announcer or characters, a video or clip, a song, an article read aloud, other people); build segments and pick quotes only from the streamer's own lines. (1) Only the streamer's own talk is theirs: their opinions, stories, rants, jokes, plans, reactions and conversations with chat. A game's announcer or character lines (\"You have slain an enemy\", \"First blood\", \"Victory\"), lyrics, ads, a video's narration or an article read aloud are never the streamer; when they react to an article or a video, its subject is the topic and their reaction is the take. A streamer who talks in one language most of the time does not break into short theatrical one-liners in another: dramatic lines in a different language (often English) dropped between their own talk, like \"A great queen needs tenacity.\" or \"Fear what I'll do now.\", are the game's characters, not roleplay or the streamer, so they are never a segment, a take or a quote. In a game stream, short lines in a character's voice (boasts, threats, catchphrases, solemn or poetic lines such as \"Kneel before me or bleed out.\", \"No one can find the queen!\", \"Only those you love can break your heart.\" or \"I would cut off my wings if there were steel strong enough.\") are the game even in English: the streamer is not roleplaying or being poetic. A sports game's commentators (\"We present…\", play-by-play, player names and tactics read out) are the game too. Lines that do not make sense (\"GCC embedded diablo, but controlling topic albanese\") are mistranscriptions: skip them and never invent a meaning for them. (2) Split the stretch into segments, one per thing they talked about, at most 4, in order. Skip stretches that are only gameplay noise, mumbling or garbled text; an empty list is a good answer when nothing was said. Play-by-play is not a segment: reacting to kills, deaths, teammates, plays, rounds or a fight as it happens is the game itself, and the stream's recap covers it. A segment about a game needs something a reader could follow without watching: an opinion (a champion is broken, matchmaking is rigged), a story, a plan, a running bit, an argument. A stretch that is only play-by-play gets at most one segment, and only for a standout moment. Never make a segment that is more of what Earlier in this stream already covered. (3) topic: a short concrete name for the subject, the way a headline names it (\"Supermarket fridges hacked in France\", \"League ranked matchmaking\", \"Moving apartments\"), never vague (\"news\", \"gaming\", \"chatting\", \"the stream\"), never one of the kinds below (\"chat\", \"games\", \"life\"…) and never a mood or a description of the stream (\"Frustration and chaos in the match\", \"Talking with chat and teammates\", \"Mid-game rage\"): name the subject, for a game the game and the thing (\"Briar in League\", \"League autofill\", \"Minecraft base building\"), for something they watched what it was (\"Boxing fight night\"); when nothing more specific fits, name what they were doing (\"Theme park rides\"). When it is the same specific subject as one of the known topics, return that name exactly; never file a segment under a known topic only because it is the same game or the same mood. (4) kind: news, games, tech, life, music, sports, money, internet, chat (talking with chat about the stream itself) or other. (5) title: a punchy headline for the segment, at most 70 characters. what: what they talked about, plainly, one or two sentences. take: their opinion or stance in one sentence, or null when they had none. quip: one line from the desk about the take or the situation, dry and a little savage, the way a late-night host would rib it, at most 160 characters. score: how much a reader who missed the stream would enjoy reading it, 1 (filler) to 5 (the best thing said all night): play-by-play and in-game reactions are 2 at most unless something standout happened, most segments are a 3, and a 4 or 5 needs a real opinion, story or line. (6) quotes: 0 to 2 lines of the streamer's own words, copied character for character from the transcript lines (part of one line is fine), the funniest or most telling, each with its t. A quote is something they said themselves: never a sentence they read out from an article, a post, a tweet, a chat message or a video, even when it is in quotation marks in the source; when unsure, leave it out. When a quote is not in English, en is an English translation; otherwise en is null. (7) start and end are seconds from the lines. Write topic, title, what, take and quip in English whatever language they speak. In what and take, call the streamer by the name given in Streamer, never \"the streamer\" or \"Streamer\". (8) Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "Streamer: {{streamer}}\nStream title: {{title}}\nCategory: {{category}}\nKnown topics (reuse the exact name for the same subject):\n{{topics}}\nEarlier in this stream: {{earlier}}\n\nTranscript lines ([seconds into the stream] text):\n{{lines}}",
user_prompt: "Streamer: {{streamer}}\nStream title: {{title}}\nCategory: {{category}}\nKnown topics (reuse the exact name for the same subject):\n{{topics}}\nEarlier in this stream: {{earlier}}\n\nTranscript lines ([seconds into the stream] text):\n{{lines}}",
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_segment' },
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_segment' },
},
},
@@ -129,7 +129,7 @@ const templates = [
@@ -129,7 +129,7 @@ const templates = [
description: "From rundown/desk.js: a finished stream's facts and segments in; an episode title, logline, recap and chapters out.",
description: "From rundown/desk.js: a finished stream's facts and segments in; an episode title, logline, recap and chapters out.",
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "logline", "recap", "chapters"], "properties": {"title": {"type": "string"}, "logline": {"type": "string"}, "recap": {"type": "string"}, "chapters": {"type": "array", "maxItems": 10, "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer", "minimum": 0}, "title": {"type": "string"}}}}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "logline", "recap", "chapters"], "properties": {"title": {"type": "string"}, "logline": {"type": "string"}, "recap": {"type": "string"}, "chapters": {"type": "array", "maxItems": 10, "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer", "minimum": 0}, "title": {"type": "string"}}}}}},
- system_prompt: "You write the episode guide of THE RUNDOWN, where every live stream is an episode. You get the stream's facts and its segments, what the streamer talked about, in order. Write: title: the episode's title, at most 60 characters, playful and specific to this stream (never just the streamer's name or \"Stream\"). logline: one sentence of at most 180 characters, the hook that makes someone open it. recap: the story of the stream in order, two or three short paragraphs (blank line between them), at most 900 characters in all, with personality: dry, teasing, roasting the takes and the situations, never mean about the person. Quote only from the quotes given, word for word. chapters: up to 8 segments worth listing, each the segment's index and a short chapter title of at most 50 characters. Copy numbers exactly. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
+ system_prompt: "You write the episode guide of THE RUNDOWN, where every live stream is an episode. You get the stream's facts and its segments, what the streamer talked about, in order. Write: title: the episode's title, at most 60 characters, playful and specific to this stream (never just the streamer's name or \"Stream\"). logline: one sentence of at most 180 characters, the hook that makes someone open it. recap: the story of the stream in order, two or three short paragraphs (blank line between them), at most 900 characters in all, with personality: dry, teasing, roasting the takes and the situations, never mean about the person. Quote only from the quotes given, word for word. chapters: up to 8 segments worth listing, each the segment's index and a short chapter title of at most 50 characters. Copy numbers exactly. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "{{json payload}}",
user_prompt: "{{json payload}}",
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_episode' },
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_episode' },
},
},
@@ -138,7 +138,7 @@ const templates = [
@@ -138,7 +138,7 @@ const templates = [
description: "From rundown/desk.js: one topic and every take on it in; a headline and a summary out.",
description: "From rundown/desk.js: one topic and every take on it in; a headline and a summary out.",
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "summary"], "properties": {"headline": {"type": "string"}, "summary": {"type": "string"}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "summary"], "properties": {"headline": {"type": "string"}, "summary": {"type": "string"}}},
- system_prompt: "You run THE RUNDOWN's topic pages. You get one topic and the takes streamers had on it, oldest first. Write: headline: at most 80 characters, punchy, about the topic and the takes. summary: two to four sentences, at most 600 characters: what the subject is, who said what, where they agree or clash, and one dry line from the desk. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
+ system_prompt: "You run THE RUNDOWN's topic pages. You get one topic and the takes streamers had on it, oldest first. Write: headline: at most 80 characters, punchy, about the topic and the takes. summary: two to four sentences, at most 600 characters: what the subject is, who said what, where they agree or clash, and one dry line from the desk. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "{{json payload}}",
user_prompt: "{{json payload}}",
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },
default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },
},
},
@@ -175,7 +175,7 @@ const templates = [
@@ -175,7 +175,7 @@ const templates = [
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},
system_prompt: "",
system_prompt: "",
- user_prompt: "You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Never describe their looks, body, clothing or age, and use their name or \"they\", never a guessed pronoun. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",
+ user_prompt: "You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Use their name or \"they\", never a guessed pronoun. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },
},
},
{
{
@@ -184,7 +184,7 @@ const templates = [
@@ -184,7 +184,7 @@ const templates = [
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["titles"], "properties": {"titles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer"}, "title": {"type": "string"}}}}}},
system_prompt: "",
system_prompt: "",
- user_prompt: "Write a SHORT, catchy stream title for each livestream summary below — the way a real streamer titles a VOD: 3 to 6 words, punchy, no surrounding quotes, no trailing period. Match each stream's vibe. Name what happens on the stream (the game, the activity, the topic), never what anyone looks like or wears (no shirts, hoodies, beanies, hats, glasses, headsets, beards, hair or faces).\n\n{{list}}\n\nReturn STRICT JSON only: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",
+ user_prompt: "Write a SHORT, catchy stream title for each livestream summary below — the way a real streamer titles a VOD: 3 to 6 words, punchy, no surrounding quotes, no trailing period. Match each stream's vibe. Name what happens on the stream (the game, the activity, the topic), people included.\n\n{{list}}\n\nReturn STRICT JSON only: {\"titles\": [{\"index\": <number>, \"title\": \"<3-6 word title>\"}]} with every item.",
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'session_titles' },
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'session_titles' },
},
},
{
{
@@ -202,7 +202,7 @@ const templates = [
@@ -202,7 +202,7 @@ const templates = [
input_schema: { type: 'object' },
input_schema: { type: 'object' },
output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},
output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},
system_prompt: '',
system_prompt: '',
- user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone. Never mention anyone's looks, body, clothing or age (no \"shirtless\", no \"young\"): talk about what they stream and say. This is the first line of the front page: nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes (\"qweqwe\").\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",
+ user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone. Talk about what they stream and say. This is the first line of the front page: nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes (\"qweqwe\").\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },
},
},
{
{
@@ -235,7 +235,7 @@ const templates = [
@@ -235,7 +235,7 @@ const templates = [
},
},
},
},
system_prompt: '',
system_prompt: '',
- user_prompt: 'Below is a single livestream VOD titled "{{title}}", described by its on-screen TIMELINE (visual scene notes) and its AUDIO TRANSCRIPT, each line timestamped [m:ss]. Titles and descriptions are about what happens, never about what people look like (body, clothing, age, ethnicity or perceived gender).\n\nTIMELINE:\n{{timeline}}\n\nTRANSCRIPT:\n{{transcript}}\n\nSOUNDS HEARD (non-speech audio events):\n{{sounds}}\n\nViewers CLIPPED these timestamps (very strong "this was a highlight" signal): {{clipped}}\nChat activity SPIKED around: {{spikes}}\n\n{{flavor_hint}}\n{{#avoid}}ALREADY USED (a paste or clip exists there) — do NOT pick these or anything within 2 minutes of them: {{avoid}}. Find a DIFFERENT moment.{{/avoid}}\n\nFind the SINGLE most interesting/funny/dramatic/surprising/striking moment in this VOD with something clearly VISIBLE happening. Prefer moments backed by the clip/chat signals when they line up with something notable. NEVER pick a black/dark/loading/blank screen, an intro/BRB card, or a moment with no visible content or activity. Return STRICT JSON only, nothing else: {"t": <seconds into the vod>, "title": "<specific punchy 3-8 word title, not the stream name, funny, never generic>", "desc": "<one vivid sentence describing the moment>"}',
+ user_prompt: 'Below is a single livestream VOD titled "{{title}}", described by its on-screen TIMELINE (visual scene notes) and its AUDIO TRANSCRIPT, each line timestamped [m:ss]. Titles and descriptions are about what happens.\n\nTIMELINE:\n{{timeline}}\n\nTRANSCRIPT:\n{{transcript}}\n\nSOUNDS HEARD (non-speech audio events):\n{{sounds}}\n\nViewers CLIPPED these timestamps (very strong "this was a highlight" signal): {{clipped}}\nChat activity SPIKED around: {{spikes}}\n\n{{flavor_hint}}\n{{#avoid}}ALREADY USED (a paste or clip exists there) — do NOT pick these or anything within 2 minutes of them: {{avoid}}. Find a DIFFERENT moment.{{/avoid}}\n\nFind the SINGLE most interesting/funny/dramatic/surprising/striking moment in this VOD with something clearly VISIBLE happening. Prefer moments backed by the clip/chat signals when they line up with something notable. NEVER pick a black/dark/loading/blank screen, an intro/BRB card, or a moment with no visible content or activity. Return STRICT JSON only, nothing else: {"t": <seconds into the vod>, "title": "<specific punchy 3-8 word title, not the stream name, funny, never generic>", "desc": "<one vivid sentence describing the moment>"}',
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_pick' },
default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_pick' },
},
},
{
{
@@ -251,7 +251,7 @@ const templates = [
@@ -251,7 +251,7 @@ const templates = [
grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
},
},
},
},
- system_prompt: 'You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Never describe anyone\'s looks, body, clothing or age. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.',
+ system_prompt: 'You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.',
user_prompt: '{{json facts}}',
user_prompt: '{{json facts}}',
default_route: 'live.summary', owner: 'live', metadata: { json_name: 'stream_recap' },
default_route: 'live.summary', owner: 'live', metadata: { json_name: 'stream_recap' },
},
},
@@ -35,7 +35,7 @@ function productTemplate(key, name, task, route, extraUser = '') {
@@ -35,7 +35,7 @@ function productTemplate(key, name, task, route, extraUser = '') {
const templates = [
const templates = [
productTemplate('wiki.generate_space', 'Wiki: plan a wiki', 'Plan a wiki about the topic, written like an encyclopedia or a well-kept fan wiki, from the numbered sources. `title`: the wiki\'s name (usually the topic\'s name). `summary`: one paragraph saying what the topic is, cited. `pages`: the main page first, titled exactly as the topic is best known, with kind overview; then the subjects a reader would look up, as many as the sources say enough about, up to {{max_pages}} pages in all: the people or characters, works (episodes, seasons, films, books, games, albums), places, groups, objects, concepts, events, and its production and reception when the sources cover them. Each page: `title` (its usual name; add the topic in brackets only to tell two subjects apart), `kind`, `parent` (the title of another page in this list it belongs under, else an empty string; the main page has none), `outline` (three to six short section headings this page should have), `query` (a short web search phrase that finds this subject, such as "Ron Stoppable Kim Possible"), and `citations`. Only plan subjects the sources name; leave out anything they do not support.', 'wiki.write',
productTemplate('wiki.generate_space', 'Wiki: plan a wiki', 'Plan a wiki about the topic, written like an encyclopedia or a well-kept fan wiki, from the numbered sources. `title`: the wiki\'s name (usually the topic\'s name). `summary`: one paragraph saying what the topic is, cited. `pages`: the main page first, titled exactly as the topic is best known, with kind overview; then the subjects a reader would look up, as many as the sources say enough about, up to {{max_pages}} pages in all: the people or characters, works (episodes, seasons, films, books, games, albums), places, groups, objects, concepts, events, and its production and reception when the sources cover them. Each page: `title` (its usual name; add the topic in brackets only to tell two subjects apart), `kind`, `parent` (the title of another page in this list it belongs under, else an empty string; the main page has none), `outline` (three to six short section headings this page should have), `query` (a short web search phrase that finds this subject, such as "Ron Stoppable Kim Possible"), and `citations`. Only plan subjects the sources name; leave out anything they do not support.', 'wiki.write',
'Topic: {{topic}}{{#audience}}\nAudience: {{audience}}{{/audience}}\nPages at most: {{max_pages}}\n\n'),
'Topic: {{topic}}{{#audience}}\nAudience: {{audience}}{{/audience}}\nPages at most: {{max_pages}}\n\n'),
- productTemplate('wiki.generate_page', 'Wiki: write a page', 'Write one page of the wiki from the numbered sources, encyclopedic and engaging, the way a well-kept wiki reads. `summary`: one or two plain sentences saying what the subject is. `lead`: the opening paragraph or two, in Markdown. `sections`: follow the requested outline where the sources support it (skip or merge headings they do not), each with a `heading` and a Markdown `body` of one or more full paragraphs (a list where it reads better), each citing its sources; about 500 to 1500 words in all when the sources allow, shorter when they do not. Link other pages of this wiki, and only titles from the list of pages, as [[Exact Title]] the first time each is mentioned. `infobox`: up to twelve key facts as `label` and `value` (for example First appearance, Voiced by, Created by, Release date, Location), each cited; the Wikidata facts are good material. `images`: up to three pictures from the numbered list of pictures that clearly show this page\'s subject, judged by their title and description (never a picture of something or someone else, and none when no picture fits), each with its `index`, a short factual `caption`, and `section`: the heading it illustrates, or lead for the page\'s main picture. Never write source numbers or "[n]" in the text. Never describe a real person\'s looks, body, clothing or age.', 'wiki.write',
+ productTemplate('wiki.generate_page', 'Wiki: write a page', 'Write one page of the wiki from the numbered sources, encyclopedic and engaging, the way a well-kept wiki reads. `summary`: one or two plain sentences saying what the subject is. `lead`: the opening paragraph or two, in Markdown. `sections`: follow the requested outline where the sources support it (skip or merge headings they do not), each with a `heading` and a Markdown `body` of one or more full paragraphs (a list where it reads better), each citing its sources; about 500 to 1500 words in all when the sources allow, shorter when they do not. Link other pages of this wiki, and only titles from the list of pages, as [[Exact Title]] the first time each is mentioned. `infobox`: up to twelve key facts as `label` and `value` (for example First appearance, Voiced by, Created by, Release date, Location), each cited; the Wikidata facts are good material. `images`: up to three pictures from the numbered list of pictures that clearly show this page\'s subject, judged by their title and description (never a picture of something or someone else, and none when no picture fits), each with its `index`, a short factual `caption`, and `section`: the heading it illustrates, or lead for the page\'s main picture. Never write source numbers or "[n]" in the text.', 'wiki.write',
'Page title: {{title}}\nWiki topic: {{topic}}{{#outline}}\nRequested outline: {{json outline}}{{/outline}}\n\nPages of this wiki (link only these, as [[Title]]):\n{{pages_block}}\n\n{{#images_block}}Pictures (by number):\n{{images_block}}\n\n{{/images_block}}'),
'Page title: {{title}}\nWiki topic: {{topic}}{{#outline}}\nRequested outline: {{json outline}}{{/outline}}\n\nPages of this wiki (link only these, as [[Title]]):\n{{pages_block}}\n\n{{#images_block}}Pictures (by number):\n{{images_block}}\n\n{{/images_block}}'),
productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'blog.draft',
productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'blog.draft',
'Topic: {{topic}}{{#brief}}\nBrief: {{brief}}{{/brief}}{{#tone}}\nTone: {{tone}}{{/tone}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
'Topic: {{topic}}{{#brief}}\nBrief: {{brief}}{{/brief}}{{#tone}}\nTone: {{tone}}{{/tone}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
@@ -265,7 +265,7 @@ templates.push({
@@ -265,7 +265,7 @@ templates.push({
key: 'pics.describe_image', name: 'Pics: describe a picture',
key: 'pics.describe_image', name: 'Pics: describe a picture',
description: 'Alt text, a short caption and tags for one picture on OpenVibe.Pics.',
description: 'Alt text, a short caption and tags for one picture on OpenVibe.Pics.',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
- system_prompt: 'You describe one picture for OpenVibe.Pics so people who cannot see it know what it shows. alt: one plain sentence of at most 140 characters saying what the picture shows (never "image of" or "picture of"). caption: one or two sentences with the detail a sighted person would notice first. tags: up to six short lowercase words for what is in it. Describe only what is visible. Never name or guess who a real person is, and never read out personal details shown in it (names, addresses, number plates, phone numbers, faces of children described by age or appearance beyond "a child"). If it shows text, say what kind of text it is rather than quoting personal parts. Output only JSON matching the schema.',
+ system_prompt: 'You describe one picture for OpenVibe.Pics so people who cannot see it know what it shows. alt: one plain sentence of at most 140 characters saying what the picture shows (never "image of" or "picture of"). caption: one or two sentences with the detail a sighted person would notice first. tags: up to six short lowercase words for what is in it. Describe only what is visible. Never name or guess who a real person is, and never read out personal details shown in it (names, addresses, number plates, phone numbers). If it shows text, say what kind of text it is rather than quoting personal parts. Output only JSON matching the schema.',
user_prompt: '{{#title}}The uploader titled it: {{title}}\n{{/title}}Describe the picture.',
user_prompt: '{{#title}}The uploader titled it: {{title}}\n{{/title}}Describe the picture.',
default_route: 'pics.vision', owner: 'media-hub', metadata: { json_name: 'pics_describe_image' },
default_route: 'pics.vision', owner: 'media-hub', metadata: { json_name: 'pics_describe_image' },
});
});
@@ -108,27 +108,20 @@ t.test('the AI viewers render as Live rendered them', () => {
@@ -108,27 +108,20 @@ t.test('the AI viewers render as Live rendered them', () => {
assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);
assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);
});
});
-t.test("a channel description is public prose: never about looks, no lists or labels reach the page", () => {
+t.test("AI text may describe people: no looks ban in the public prose prompts", () => {
const tp = (key) => live.templates.find((x) => x.key === key);
const tp = (key) => live.templates.find((x) => x.key === key);
const prompt = tp('live.streamer.overview').user_prompt;
const prompt = tp('live.streamer.overview').user_prompt;
assert.match(prompt, /public description/);
assert.match(prompt, /public description/);
- assert.match(prompt, /Never describe anyone's appearance, body, clothing, age/);
+ assert.doesNotMatch(prompt, /Never describe anyone's appearance|never about what people look like/);
assert.match(prompt, /never guess pronouns/);
assert.match(prompt, /never guess pronouns/);
- assert.ok(!/moderation\.?$|notable for moderation/m.test(prompt), 'no staff-only moderation notes');
- assert.match(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);
- // Frame notes are public (a VOD page's timeline) and feed every summary: none describes how people look.
- assert.match(tp('live.stream.describe_frame').user_prompt, /never mention anyone's body, clothing, age/);
- assert.match(tp('live.stream.summarize').user_prompt, /never about what people look like/);
- assert.match(tp('live.moments.pick').user_prompt, /never about what people look like/);
- assert.match(tp('live.stream.recap').system_prompt, /Never describe anyone's looks/);
- assert.match(tp('live.hero.slogans').user_prompt, /Never mention anyone's looks, body, clothing or age/);
- assert.match(tp('live.hero.slogans').user_prompt, /nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes/);
- assert.match(tp('live.stream.describe_frame').user_prompt, /caption naming what happens, never what someone looks like or wears/);
- assert.match(tp('live.stream.titles').user_prompt, /never what anyone looks like or wears/);
- const out = POSTPROCESS['live.streamer.overview']({ text: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios\n- Tone: calm, patient\n\nAnn streams games: mostly Rust.' });
- assert.deepStrictEqual(out, { overview: 'finditfixit (@finditfixit). repairs old TVs and radios. calm, patient. Ann streams games: mostly Rust.' });
- assert.strictEqual(POSTPROCESS['live.streamer.overview']({ text: '## Overview\n' }), null, 'nothing but a heading is no overview');
- assert.deepStrictEqual(POSTPROCESS['live.media.overview']({ text: 'They build a PC.\nThen they test it' }), { overview: 'They build a PC. Then they test it.' });
+ assert.doesNotMatch(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);
+ assert.doesNotMatch(tp('live.stream.describe_frame').user_prompt, /never mention anyone's body/);
+ assert.doesNotMatch(tp('live.stream.summarize').user_prompt, /never about what people look like/);
+ assert.doesNotMatch(tp('live.moments.pick').user_prompt, /never about what people look like/);
+ assert.doesNotMatch(tp('live.stream.recap').system_prompt, /Never describe anyone's looks/);
+ assert.doesNotMatch(tp('live.hero.slogans').user_prompt, /Never mention anyone's looks/);
+ assert.match(tp('live.hero.slogans').user_prompt, /nothing crude or sexual/);
+ assert.doesNotMatch(tp('live.stream.titles').user_prompt, /never what anyone looks like or wears/);
});
});
t.test('The Rundown: the segment prompt lists the lines and known topics; its output is clamped, kept to the lines and stripped of empty segments', () => {
t.test('The Rundown: the segment prompt lists the lines and known topics; its output is clamped, kept to the lines and stripped of empty segments', () => {
@@ -216,7 +216,7 @@ t.test('a conflict is marked, hidden from an agent, and the person settles it',
@@ -216,7 +216,7 @@ t.test('a conflict is marked, hidden from an agent, and the person settles it',
} finally { await h.stop(); await seam.close(); }
} finally { await h.stop(); await seam.close(); }
});
});
-t.test('the profile is built from at most 60 notes, cached by items_hash, once an hour, and passes noLooks', async () => {
+t.test('the profile is built from at most 60 notes, cached by items_hash, once an hour, and is stored as written', async () => {
let now = Date.now();
let now = Date.now();
const clock = { now: () => now };
const clock = { now: () => now };
const { seam, state } = await startSeam();
const { seam, state } = await startSeam();
@@ -236,7 +236,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once a
@@ -236,7 +236,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once a
const first = await request(h.base, 'GET', '/api/v1/memory/profile?scope=user', { tok: who });
const first = await request(h.base, 'GET', '/api/v1/memory/profile?scope=user', { tok: who });
assert.equal(first.status, 200, `profile: ${first.text}`);
assert.equal(first.status, 200, `profile: ${first.text}`);
const text = first.body.profile.text;
const text = first.body.profile.text;
- assert.ok(!text.includes('shirtless') && !text.includes('man'), `noLooks did not run: ${text}`);
+ assert.ok(text.includes('shirtless') && text.includes('man'), `the profile is stored as written: ${text}`);
assert.ok(text.length <= 4000, 'the profile is at most about a thousand tokens');
assert.ok(text.length <= 4000, 'the profile is at most about a thousand tokens');
// The prompt carries the person's own notes only, and at most 60 of them, newest first.
// The prompt carries the person's own notes only, and at most 60 of them, newest first.
@@ -268,7 +268,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once a
@@ -268,7 +268,7 @@ t.test('the profile is built from at most 60 notes, cached by items_hash, once a
assert.equal(state.chats, 2, 'the profile was rebuilt after the hour');
assert.equal(state.chats, 2, 'the profile was rebuilt after the hour');
assert.notEqual(rebuilt.body.profile.built_at, first.body.profile.built_at);
assert.notEqual(rebuilt.body.profile.built_at, first.body.profile.built_at);
assert.notEqual(rebuilt.body.profile.items_hash, first.body.profile.items_hash);
assert.notEqual(rebuilt.body.profile.items_hash, first.body.profile.items_hash);
- assert.ok(!rebuilt.body.profile.text.includes('shirtless'), 'the rebuilt profile passes noLooks too');
+ assert.ok(rebuilt.body.profile.text.includes('shirtless'), 'the rebuilt profile is stored as written too');
} finally { await h.stop(); await seam.close(); }
} finally { await h.stop(); await seam.close(); }
});
});
t.test('the job walks owner by owner, within its caps', async () => {
t.test('the job walks owner by owner, within its caps', async () => {
@@ -328,7 +328,7 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth
@@ -328,7 +328,7 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
-t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and passes noLooks', async () => {
+t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and is stored as written', async () => {
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
try {
try {
let now = Date.parse('2026-10-30T09:00:00.000Z');
let now = Date.parse('2026-10-30T09:00:00.000Z');
@@ -340,7 +340,7 @@ t.test('the profile is cached, rebuilt only when items_hash moves and at most on
@@ -340,7 +340,7 @@ t.test('the profile is cached, rebuilt only when items_hash moves and at most on
await m.batch({ principal: req.principal, body: { items: [item('one note'), item('two notes')] } }, owner);
await m.batch({ principal: req.principal, body: { items: [item('one note'), item('two notes')] } }, owner);
const first = await m.profile(req, owner);
const first = await m.profile(req, owner);
assert.equal(calls, 1);
assert.equal(calls, 1);
- assert.ok(!first.profile.text.includes('shirtless') && !first.profile.text.includes('man'), `noLooks did not run: ${first.profile.text}`);
+ assert.ok(first.profile.text.includes('shirtless') && first.profile.text.includes('man'), `the profile is stored as written: ${first.profile.text}`);
assert.equal(first.profile.text.includes('[redacted]'), false);
assert.equal(first.profile.text.includes('[redacted]'), false);
const second = await m.profile(req, owner);
const second = await m.profile(req, owner);
assert.equal(calls, 1, 'the same items_hash is served from the cache');
assert.equal(calls, 1, 'the same items_hash is served from the cache');
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old
+// looks filter again.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) walk(p);
+ else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));
+ }
+})(path.join(root, 'server'));
+assert.deepStrictEqual(offenders, [], 'no looks filter in server/');
+console.log('No looks filter: AI text describes people as written.');