openvibers/openvibe.exchange Commits

Companions: character cards, sharing, the gallery and branching chats (#84)

OP OpenVibers committed 7 days ago · 6f326dc661 · 26 files, +2849 −15

* Exchange, remade: every AI model, one account

The site is now the network's AI front, read from OpenVibe.AI:

- Home with live numbers (popular by requests, cheapest per million, uncensored,
  community machines, the endpoint, AI in OpenVibe).
- /models and /m/<org>/<model>: every offer with price in/out per million, p50/p95,
  uptime and data policy; a Try it box; curl and the OpenAI SDK line.
- /chat on the shared conversation UI (openvibe-shared/thread): one to three models
  side by side, system prompt, temperature, history in the account, a daily
  allowance, and the 18+ gate for an uncensored model. Chat and Try it are
  ai.complete runs on the person's behalf.
- /api, /sell (with what is not sold, said plainly), /uncensored (the floor) and
  /ai (product, models and daily cost).

Exchange v1 is retired: every v1 page answers 410 with a pointer, /api/v1 answers
410 exchange.retired, its tables stay read-only, and its tests move to
test/retired/. New migration 0005_chat.sql; account export/deletion covers the
chats and the 18+ confirmation.

* Merge remote-tracking branch 'origin/main' into exchange/remake-site

# Conflicts:
#	README.md

* Exchange: the design review, on the one column

- One centred column everywhere: the head's title and every section start on the
  same 16 px line at 390 and 1280; links sit inside their cards.
- Hero: the stray gradient panel is gone; at 1024 px a side-by-side answer preview
  (family marks, live prices and speed, example words said as an example).
- Numbers: models, providers, cheapest per million and the free daily allowance;
  requests only from 1,000 a day.
- Popular now is one list of up to eight rows (mark, badges, price, speed, uptime,
  Try it), then a three-card strip for the product lines. No repeated model cards.
- Family marks are inline SVG glyphs on theme tokens, never initials in a circle;
  products use their own app marks and full names (OpenVibe.Live).
- Model page: privacy label per offer (Private, Community, Standard), refusal rate
  when one exists, a Presets box (the aliases that work today, the saved shape),
  and a stacked card table on a phone.
- Chat: two or three answers in columns at 1024 px, headed by the model mark, that
  answer's price and latency, with a folded receipt (offer, price, tokens,
  latency, why); the empty avatar above the first answer is gone.
- /sell: seller card, idle-only, kudos, and the privacy sentence said plainly.
- New /status: uptime per offer, the model changelog and incidents.
- AI in OpenVibe: product marks, full names and cost a day.

Fresh screenshots of every page at 390 and 1280 px, dark and light, in
/tmp/exchange-shots; no page scrolls sideways. npm test -- pages chat retirement
account-data agent-layer migration sso: 7/7.

* Exchange: one card per section, and the Anonymized label

- kit.section is already the card, so a section no longer draws a second border
  around itself and a third around its rows: Popular now, Use it from code and AI
  in OpenVibe are one card each, with the list or table flush inside.
- Section prose keeps the heading's left edge (the "Use it from code" paragraph).
- AI in OpenVibe's product mark is 28 px, the same as the model marks.
- A provider that says it anonymises before the model sees the request gets the
  Anonymized privacy label; nothing is shown for an offer where Bench has no
  refusal rate.

* Exchange: keep /openapi.json, and say only what the API says publicly

- /openapi.json reads https://api.openvibe.exchange/openapi.json server-side,
  keeps it ten minutes, sets servers[0] to the API host, and answers from the
  last copy (or a minimal document) when the host is down. Tests cover the
  fetch-once cache, the server URL and the fallback.
- The read API's kind (or type) is read, and every list shows only chat and
  vision models, so embeddings and speech models do not leak into the chat
  catalogue.
- A provider's internal label ("Shared provider (Live's shared key)", "Local
  model (OpenAI-compatible server on this host)") and its access codes are
  never printed: an offer is its public name, OpenVibe for OpenVibe's own
  capacity, or a community machine.

* Exchange: a quiet Free to try badge for free_tier offers

The read API's free_tier (true only where an offer is inside the free-use
ceiling: DeepSeek, local and community capacity) marks the model in Popular
now, in /models, in the model page head and on the offer row. The chat picker
marks the free-tier chips and says plainly that other models need your own
balance or key. No field, no badge.

* Merge remote-tracking branch 'origin/main' into feature/companions-stage1-2

* Merge remote-tracking branch 'origin/main' into feature/companions-stage1-2

* Companions: character cards, sharing, the gallery and branching chats

Stage 1 (cards): create and edit a card, Character Card V2/V3 import and export
(the PNG with the card inside, and plain JSON), the character page at /c/<handle>/<character>,
sharing through openvibe-sdk/share (private by default, unlisted, public, and a remix that
credits the original unless the creator switches remixing off), and the gallery with search,
tags, the three sorts and a counted fold.

Stage 2 (chats): one-character chats on openvibe-shared's thread UI, with a branching tree
(editing a message or regenerating an answer keeps every branch), the folded receipt, the
per-chat settings (model or preset, temperature, context size, persona) and the "what the
model saw" panel. Chats run through the AI router on the person’s own allowance, under the
free-use price ceiling.

The hard floor is checked on the card at save, on the input and on the output: no sexual
content involving minors (a card rated 18+ whose stated age is under 18 is refused at save),
no non-consensual sexual content of real people, no help with weapons of mass harm or attacks
on others. An answer that breaks it is never delivered: the chat ends and the account is
reviewed. 18+ cards show only to people signed in with the 18+ confirmation, and never reach a
public list, a sitemap, llms.txt or the search.

Checked with node test/companions.test.js (21 checks: the gallery, the three sorts, the counted
fold, private/unlisted/public, the remix and the creator’s switch, the 18+ gate, the sitemap,
the V2/V3 export and import, the chat, the branch tree, the floor on the card, the input and the
output), and with node test/pages.test.js, node test/chat.test.js and node test/migration.test.js.

* Companions: the gallery grid needs a real minimum track

* Companions: the 18+ confirmation comes from the account, and the design review's screenshots

The 18+ gate now reads the account's own flag, the way OpenVibe.AI reads it for the uncensored
tier. Network owns the confirmation (Network PR #587): the flag sits on the account row, and
Network serves it at GET /api/v1/me/adult. Network grants Exchange no identity.subject.resolve,
so the read goes out with the person's own session token - the token this site already holds -
and the service-token route stays as the fallback for the day Network grants it. Exchange's own
row still counts, so nobody who confirmed here loses access. Both fail closed: an unreadable
flag is never a yes.

/uncensored now says what the floor does to characters and chats, and carries the dated changelog
entry the design asks for.

The screenshots for the design review are in screenshots/ (the gallery, a character and a chat, at
390 and 1280, dark and light), taken by scripts/shoot-companions.js, which also checks the width,
the console and the tap targets. Three tap targets grew to a finger: the chips, the export links and
the shared thread UI's own controls on the chat page.

* Companions: a person's characters and chats are theirs to export and to erase

The account export and deletion (ADR-033) cover every table that names a person, and the three
new companion tables were not in it. They are now: the messages, then the chats, then the
characters, each into its own file, and each erased with the account.

A character may be deleted while somebody else's chat still points at it, so the chat's
character_id is now nullable and the reference is set to null rather than cascading the other
person's messages away.

* Companions: the sharing tables are a migration, so every database has them

The sharing system's DDL was applied by the SDK's own migrateShares at boot. That works on the
embedded database and on a development machine, but not on PostgreSQL through PgBouncer: the
runtime role may not run DDL, so the tables were missing there and every card came out private
(the share row never existed). The test database, which applies the migrations directory, missed
them for the same reason.

The DDL is now migrations/0007_sharing.sql, kept verbatim from openvibe-sdk/share v0.91.0, so
the migration role applies it and the test database has it too. The runtime call is gone.

The sharing tables name a person (a member, an access request) with a jsonb subject, which the
account data's table map cannot express, so those rows are erased with the rest of the person's
rows in extraErase, and the tables are exempted from the coverage check with that reason.

* WIP: companions review fixes (screenshots out of the repo, monograms, chat layout) saved from an ended session; not reviewed yet

* Companions: the design review's fixes, finished

The review asked for five things. This commit has them.

- No screenshots in the repository: the twelve shots are out of the
  history, screenshots/ is ignored, and the script writes them to a
  folder outside the repository.
- A monogram wherever a character has no picture: the gallery tile, the
  character page and the chat header. The first letter, on a colour the
  name picks out of the kit's palette, so the same name always gets the
  same colour. The letter is that colour mixed towards the panel's text
  colour, so it reads in a light theme and in a dark one (4.95:1 to
  7.29:1).
- One header on the chat page: the site's own bar is not there, so the
  chat's header is the top of the window and the thread starts under it.
- The quiet actions: a small Edit under the person's message, which
  turns into the box in place, and Regenerate, Copy and the branch arrows
  under the answer. They are there on hover and on focus, and always on a
  touch screen. The folded receipt stays.
- The chat header: the character's face or monogram, its name, and the
  model or preset as a chip that opens the settings.

Two things the review's items turned up on the way:

- The branch line could not be drawn at all: branchUrl() read a chat
  that was not in its scope, so every turn with branches answered 500.
  The tree now walks every version of the message and its answers.
- The model saw the message it was answering twice: it was already in
  the history and was added again at the end. Each message is sent once,
  and the panel that shows the context says the same.

Checked with ov test companions, pages, chat and migration, all green,
and with node scripts/shoot-companions.js: the gallery, a character and a
chat at 390 and 1280, in dark and light, with no sideways scroll, no
console errors of ours and every tap target 44 px or more. The shots are
not in the repository.

* Merge remote-tracking branch 'origin/main' into feature/companions-stage1-2

Changes

ddiff --git a/.gitignore b/.gitignore
iindex d2a8a4e..77edc5b 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -9,3 +9,6 @@ dist/
*.pem
*.key
!server/data/
++
++# The design review screenshots stay out of the repository: they are pictures of the site, not code.
++screenshots/
ddiff --git a/migrations/0006_companions.sql b/migrations/0006_companions.sql
nnew file mode 100644
iindex 0000000..c42de6a
---- /dev/null
++++ b/migrations/0006_companions.sql
@@@ -0,0 +1,92 @@
++-- phase: expand
++-- Companions on OpenVibe.Exchange (Opus's design, 2026-11-06): character cards, the gallery and one-character
++-- chats with a branching tree. Expand-only: nothing here changes an existing table.
++
++CREATE TABLE IF NOT EXISTS exchange_characters (
++ id text COLLATE "C" PRIMARY KEY, -- exc_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ handle text COLLATE "C" NOT NULL, -- the creator's OpenVibe handle (the page's first path part)
++ slug text COLLATE "C" NOT NULL, -- the character's own slug (the page's second path part)
++ name text NOT NULL,
++ description text NOT NULL DEFAULT '',
++ personality text NOT NULL DEFAULT '',
++ scenario text NOT NULL DEFAULT '',
++ first_message text NOT NULL DEFAULT '',
++ example_dialogue text NOT NULL DEFAULT '',
++ avatar_url text,
++ tags text COLLATE "C" NOT NULL DEFAULT '[]', -- the tags, as a JSON array (the tag table is the queryable copy)
++ rating text COLLATE "C" NOT NULL DEFAULT 'everyone',
++ age integer,
++ model text, -- the model the card recommends ("works best on …")
++ voice text,
++ audience text COLLATE "C" NOT NULL DEFAULT 'private', -- the sharing audience, kept in step with the share row
++ allow_remix boolean NOT NULL DEFAULT true,
++ share_id text, -- the openvibe-sdk/share resource id (the character id)
++ remix_of text, -- the character this one was remixed from
++ original_author text, -- the original creator's handle, credited on the copy
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_characters_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_characters_rating CHECK (rating IN ('everyone', 'adult')),
++ CONSTRAINT exchange_characters_audience CHECK (audience IN ('private', 'unlisted', 'public')),
++ CONSTRAINT exchange_characters_age CHECK (age IS NULL OR (age BETWEEN 0 AND 999)),
++ CONSTRAINT exchange_characters_name CHECK (length(name) BETWEEN 1 AND 80)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS exchange_characters_slug ON exchange_characters (handle, slug);
++CREATE INDEX IF NOT EXISTS exchange_characters_gallery ON exchange_characters (audience, created_at DESC);
++CREATE INDEX IF NOT EXISTS exchange_characters_owner ON exchange_characters (owner, updated_at DESC);
++CREATE INDEX IF NOT EXISTS exchange_characters_remix ON exchange_characters (remix_of) WHERE remix_of IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS exchange_character_tags (
++ character_id text COLLATE "C" REFERENCES exchange_characters (id) ON DELETE SET NULL, -- the character may be deleted while somebody else's chat still points at it
++ tag text COLLATE "C" NOT NULL,
++ PRIMARY KEY (character_id, tag)
++);
++CREATE INDEX IF NOT EXISTS exchange_character_tags_tag ON exchange_character_tags (tag);
++
++CREATE TABLE IF NOT EXISTS exchange_character_views (
++ id bigserial PRIMARY KEY,
++ character_id text COLLATE "C" NOT NULL REFERENCES exchange_characters (id) ON DELETE CASCADE,
++ viewer text COLLATE "C",
++ viewed_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS exchange_character_views_recent ON exchange_character_views (character_id, viewed_at DESC);
++
++CREATE TABLE IF NOT EXISTS exchange_companion_chats (
++ id text COLLATE "C" PRIMARY KEY, -- exc_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL,
++ character_id text COLLATE "C" NOT NULL REFERENCES exchange_characters (id) ON DELETE CASCADE,
++ title text NOT NULL DEFAULT 'New chat',
++ model text, -- the model asked for (a slug), or null for the router's choice
++ preset text, -- the preset asked for, when one is used
++ temperature double precision,
++ context_size integer, -- how much context the person wants the model to see
++ persona text, -- the persona the person plays
++ ended_at timestamptz, -- set when the hard floor ended the chat
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_companion_chats_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_companion_chats_temperature CHECK (temperature IS NULL OR (temperature >= 0 AND temperature <= 2)),
++ CONSTRAINT exchange_companion_chats_context CHECK (context_size IS NULL OR (context_size BETWEEN 512 AND 200000))
++);
++CREATE INDEX IF NOT EXISTS exchange_companion_chats_owner ON exchange_companion_chats (owner, updated_at DESC);
++
++-- The chat's tree. Every message belongs to a turn group (turn_id); editing a message adds a variant to that
++-- group and regenerating adds another answer to it, so the tree keeps every branch instead of overwriting one.
++CREATE TABLE IF NOT EXISTS exchange_companion_messages (
++ id text COLLATE "C" PRIMARY KEY, -- exm_ + 26 Crockford base32 characters (ULID)
++ chat_id text COLLATE "C" NOT NULL REFERENCES exchange_companion_chats (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL, -- denormalised for export and deletion
++ role text COLLATE "C" NOT NULL, -- user | assistant
++ content text,
++ turn_id text COLLATE "C" NOT NULL, -- the branch group this message belongs to
++ variant integer NOT NULL DEFAULT 0, -- which branch of the group this is
++ parent_id text COLLATE "C", -- the user message an answer answers
++ model text,
++ meta text COLLATE "C" NOT NULL DEFAULT '{}', -- the receipt, and what the model saw
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_companion_messages_role CHECK (role IN ('user', 'assistant')),
++ CONSTRAINT exchange_companion_messages_variant CHECK (variant >= 0)
++);
++CREATE INDEX IF NOT EXISTS exchange_companion_messages_chat ON exchange_companion_messages (chat_id, turn_id, variant);
++CREATE INDEX IF NOT EXISTS exchange_companion_messages_owner ON exchange_companion_messages (owner, created_at);
ddiff --git a/migrations/0007_sharing.sql b/migrations/0007_sharing.sql
nnew file mode 100644
iindex 0000000..3e9e56e
---- /dev/null
++++ b/migrations/0007_sharing.sql
@@@ -0,0 +1,73 @@
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share v0.91.0), for the companions characters.
++-- Kept verbatim from openvibe-sdk src/share/schema.js, so the SDKs own store finds the tables it expects, and
++-- the test database (which applies this directory) has them too. The characters own rows live in
++-- exchange_characters; these rows are the audience, the members, the log and the delivery queue the SDK writes.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
ddiff --git a/package-lock.json b/package-lock.json
iindex a358449..4f6c678 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,6 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
++ "multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
@@@ -93,6 +94,12 @@
}
}
},
++ "node_modules/append-field": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
++ "license": "MIT"
++ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@@ -142,6 +149,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -1097,6 +1115,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
++ "node_modules/multer": {
++ "version": "2.5.0",
++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
++ "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
++ "license": "MIT",
++ "dependencies": {
++ "append-field": "^1.0.0",
++ "busboy": "^1.6.0",
++ "type-is": "^1.6.18"
++ },
++ "engines": {
++ "node": ">= 10.16.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -1640,6 +1676,14 @@
"node": ">= 0.8"
}
},
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
ddiff --git a/package.json b/package.json
iindex fc37ce1..70b6799 100644
---- a/package.json
++++ b/package.json
@@@ -24,6 +24,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
++ "multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex b97dcc2..f6862a7 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -263,3 +263,198 @@ body.ex-app .ovf { display: none; }
.ex-hero { padding-top: 22px; }
.ex-hero-actions .ovk-btn { flex: 1 1 auto; justify-content: center; }
}
++
++/* ── Companions: the gallery, a character page, the card form ───────────── */
++.ex-companions-head { padding: 30px 0 14px; }
++.ex-companions-head h1 { margin: 0 0 8px; font-size: clamp(1.7rem, 5vw, 2.4rem); letter-spacing: -.02em; }
++.ex-lede { margin: 0; max-width: 62ch; color: var(--ovk-muted); font-size: .95rem; line-height: 1.55; }
++.ex-gallery-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin: 18px 0 14px; }
++.ex-search { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; flex: 1 1 320px; }
++.ex-search input { min-height: 44px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .92rem; }
++.ex-search input[type=search] { flex: 1 1 200px; }
++.ex-search input[type=text] { flex: 1 1 150px; }
++.ex-sorts { display: flex; gap: 6px; flex-wrap: wrap; }
++.ex-sort { min-height: 44px; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .88rem; cursor: pointer; }
++.ex-sort.is-on { border-color: var(--ovk-accent); color: var(--ovk-accent); font-weight: 600; }
++.ex-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 9px 18px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .92rem; text-decoration: none; cursor: pointer; }
++.ex-btn-primary { background: var(--ovk-accent); border-color: var(--ovk-accent); color: var(--ovk-on-accent, #fff); font-weight: 600; }
++.ex-gallery-tags { margin: 0 0 16px; }
++.ex-taglist { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }
++.ex-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .82rem; text-decoration: none; }
++.ex-chip.is-on { border-color: var(--ovk-accent); color: var(--ovk-accent); }
++.ex-chip-18 { border-color: var(--ovk-accent); color: var(--ovk-accent); font-weight: 700; }
++.ex-chip-n { color: var(--ovk-muted); font-size: .75rem; }
++.ex-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; list-style: none; margin: 0 0 18px; padding: 0; }
++.ex-card-tile { border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); padding: 14px; }
++.ex-card-tile-link { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 12px; align-items: start; color: inherit; text-decoration: none; }
++.ex-card-face { width: 64px; height: 64px; border-radius: 12px; object-fit: cover; background: var(--ovk-panel-2, var(--ovk-panel)); }
++.ex-card-face-blank { display: inline-block; border: 1px dashed var(--ovk-line); }
++.ex-card-tile-body { display: grid; gap: 3px; min-width: 0; }
++.ex-card-tile-name { font-size: 1rem; line-height: 1.25; }
++.ex-card-tile-by { font-size: .8rem; color: var(--ovk-muted); }
++.ex-card-tile-desc { font-size: .85rem; color: var(--ovk-muted); line-height: 1.45; }
++.ex-card-tile-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
++.ex-more { margin: 0 0 22px; border-top: 1px solid var(--ovk-line); padding-top: 10px; }
++.ex-more summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: .95rem; color: var(--ovk-accent); }
++.ex-mine h2 { font-size: 1.1rem; margin: 24px 0 10px; }
++.ex-companion { max-width: 760px; }
++.ex-companion-head { display: flex; gap: 16px; align-items: center; padding: 26px 0 8px; }
++.ex-companion-head h1 { margin: 0 0 4px; font-size: clamp(1.6rem, 5vw, 2.2rem); }
++.ex-companion-avatar { width: 96px; height: 96px; border-radius: 18px; object-fit: cover; }
++.ex-companion-by { margin: 0; color: var(--ovk-muted); font-size: .88rem; }
++.ex-companion-desc { font-size: 1rem; line-height: 1.6; }
++.ex-companion-tags { display: flex; flex-wrap: wrap; gap: 6px; }
++.ex-companion-acts { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 18px 0; }
++.ex-companion-section { margin: 18px 0; }
++.ex-companion-section h2 { font-size: .78rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ovk-muted); margin: 0 0 6px; }
++.ex-companion-section p { margin: 0; line-height: 1.6; }
++.ex-companion-model, .ex-companion-meta { font-size: .85rem; color: var(--ovk-muted); }
++/* The export links act as buttons, so they get a finger-sized target at every width. */
++.ex-companion-export { display: inline-flex; align-items: center; min-height: 44px; padding: 6px 4px; }
++.ex-form { display: grid; gap: 6px; margin: 16px 0 26px; }
++.ex-form .ex-label { margin-top: 10px; font-size: .85rem; font-weight: 600; }
++.ex-form input[type=text], .ex-form textarea, .ex-form select { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .92rem; min-height: 44px; font-family: inherit; }
++.ex-form textarea { min-height: 88px; resize: vertical; }
++.ex-form button { justify-self: start; }
++.ex-hint { font-size: .8rem; color: var(--ovk-muted); line-height: 1.5; }
++.ex-inline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
++.ex-inline input[type=text] { flex: 1 1 240px; min-height: 44px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: inherit; font-size: .9rem; }
++.ex-branch-turn { list-style: none; margin: 0 0 4px; }
++.ex-branch-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
++.ex-branch-count { font-size: .78rem; color: var(--ovk-muted); }
++.ex-branch { font-size: .78rem; color: var(--ovk-accent); text-decoration: none; }
++.ex-saw-context { margin: 0; padding-left: 18px; font-size: .82rem; line-height: 1.5; }
++.ex-saw-system { white-space: pre-wrap; font-size: .78rem; line-height: 1.5; background: var(--ovk-panel); border: 1px solid var(--ovk-line); border-radius: 10px; padding: 10px; max-height: 260px; overflow: auto; }
++
++/* The companions' chat rides openvibe-shared's thread UI. On a phone the shared pieces are a touch short of
++ a finger (36-42px); this page raises them to 44px without touching the shared package. */
++.ex-companion-chat .ovk-sidebar-action,
++.ex-companion-chat .ovk-sidebar-item,
++.ex-companion-chat .ovt-btn,
++.ex-companion-chat .ovk-sidebar-find input,
++.ex-companion-chat .ov-sel,
++.ex-companion-chat .ovt-composer textarea,
++.ex-companion-chat .ovt-settings-body input,
++.ex-companion-chat .ovt-settings-body select { min-height: 44px; }
++.ex-companion-chat .ov-sel-native { min-height: 44px; }
++.ex-companion-chat .ovt-send { min-height: 44px; min-width: 44px; }
++.ex-companion-chat .ov-sel-native { position: absolute; opacity: 0; height: 44px; }
++
++/* ── Companions: the chat ──────────────────────────────────────────────── */
++/* A monogram: the first letter of the name, on a colour the name picks out of the kit's palette, so the same
++ name always gets the same colour and a card is never an empty square. */
++.ex-mono {
++ display: inline-grid; place-items: center; overflow: hidden;
++ background: color-mix(in srgb, var(--ex-mono, var(--ovk-accent)) 22%, var(--ovk-panel));
++ /* The letter is the palette colour mixed towards the panel's text colour, so it reads on the tint in a light
++ theme and in a dark one: the palette's fills alone are not text colours. */
++ color: color-mix(in srgb, var(--ex-mono, var(--ovk-accent)) 50%, var(--ovk-text));
++ font-weight: 700; letter-spacing: -.02em; line-height: 1;
++}
++.ex-card-face.ex-mono { font-size: 1.7rem; }
++.ex-companion-avatar.ex-mono { font-size: 2.4rem; }
++
++/* The chat is an app: the window does not scroll, the thread scrolls in its own pane. So the navbar stays where it
++ is, the thread starts under the one header, and nothing ever slides under a translucent bar. */
++body.ex-companion-shell { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
++/* One header on the chat page: the site's own bar (faded at the top, and another site's chrome) is not there, so the
++ chat's header is the top of the window and the thread starts under it. */
++body.ex-companion-shell #navbar-mount,
++body.ex-companion-shell .account-bar { display: none; }
++body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
++.ex-companion-chat .ovt { flex: 1; min-height: 0; height: auto; }
++.ex-companion-chat .ovt-main { min-height: 0; }
++.ex-companion-chat .ovt-scroll { overflow-y: auto; overscroll-behavior: contain; min-height: 0; }
++/* The person's side keeps its bubble and its actions together, whatever height the turn is. */
++.ex-companion-chat .ovt-you { align-content: start; }
++@media (min-width: 761px) { .ex-companion-chat .ovt > .ovk-sidebar { position: static; height: auto; } }
++@media (min-width: 1180px) { .ex-companion-chat .ovt-panel { position: static; height: auto; } }
++
++/* The chat's header: the character, its name, and the model it talks on, as a chip that opens the settings. */
++.ex-chat-head { gap: 8px; }
++.ex-chat-id { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; }
++.ex-chat-face { width: 32px; height: 32px; border-radius: 10px; object-fit: cover; flex: none; }
++.ex-chat-title { font-size: 16px; }
++.ex-chat-settings { flex: none; }
++.ex-chat-chip {
++ display: inline-flex; align-items: center; gap: 6px; max-width: 40vw;
++ min-height: 44px; padding: 0 12px; overflow: hidden; cursor: pointer; list-style: none;
++}
++.ex-chat-chip::-webkit-details-marker { display: none; }
++.ex-chat-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ex-chat-chip-caret { flex: none; font-size: .7rem; opacity: .75; }
++/* The settings drop from the header, over the thread. The header is the containing block (it is sticky). */
++.ex-chat-settings-body {
++ position: absolute; z-index: 40; right: 12px; top: calc(100% + 8px);
++ width: min(320px, calc(100% - 24px)); padding: 2px 14px 14px;
++ border-radius: 16px; border: 1px solid var(--ovk-line); background: var(--ovk-panel);
++ box-shadow: var(--ov-elev-3, 0 14px 32px rgba(0, 0, 0, .4));
++}
++.ex-chat-settings-body .ex-label { margin: 12px 0 6px; font-size: .85rem; }
++.ex-chat-settings-body select,
++.ex-chat-settings-body input,
++.ex-chat-settings-body textarea {
++ width: 100%; box-sizing: border-box; min-height: 44px; padding: 8px 10px;
++ border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input, var(--ovk-panel));
++ color: var(--ovk-text); font: inherit;
++}
++.ex-chat-settings-body textarea { min-height: 72px; resize: vertical; }
++
++/* The quiet actions under a message: small, and there on hover, on focus, and always on a touch screen. */
++.ex-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 4px; margin-top: 2px; }
++.ex-acts-you { justify-content: flex-end; }
++.ex-act {
++ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
++ min-height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none;
++ color: var(--ovk-muted); font: inherit; font-size: 12.5px; line-height: 1.2;
++ cursor: pointer; text-decoration: none;
++}
++.ex-act:hover, .ex-act:focus-visible { background: var(--ovk-panel-2); color: var(--ovk-text); }
++.ex-act-strong { color: var(--ovk-accent); font-weight: 600; }
++.ex-act-form { display: inline-flex; margin: 0; }
++
++/* Edit, in place: the summary opens the box, and the box becomes the message when it is saved. */
++.ex-menu { position: relative; }
++.ex-menu > summary { list-style: none; cursor: pointer; }
++.ex-menu > summary::-webkit-details-marker { display: none; }
++.ex-lbl-shut { display: none; }
++.ex-menu[open] .ex-lbl-open { display: none; }
++.ex-menu[open] .ex-lbl-shut { display: inline; }
++/* While the box is open the label keeps the edge the message had, above the box. */
++.ex-menu[open] > summary { display: flex; width: 100%; justify-content: flex-end; }
++.ex-menu-body {
++ display: grid; gap: 8px; justify-items: start; margin-top: 6px; padding: 10px;
++ width: min(420px, 76vw); box-sizing: border-box;
++ border-radius: 14px; border: 1px solid var(--ovk-line); background: var(--ovk-panel);
++ box-shadow: var(--ov-elev-2, 0 10px 24px -18px rgba(0, 0, 0, .7));
++}
++.ex-menu-body textarea {
++ width: 100%; box-sizing: border-box; min-height: 76px; padding: 8px 10px;
++ border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input, var(--ovk-panel));
++ color: var(--ovk-text); font: inherit; line-height: 1.5; resize: vertical;
++}
++/* While the message is being edited, it is the box. */
++.ovt-turn:has(.ex-menu[open]) .ovt-bubble { display: none; }
++
++/* The branch arrows: the turn's branch line, 1/2. */
++.ex-branch-nav { display: inline-flex; align-items: center; gap: 2px; }
++.ex-branch-arrow {
++ display: inline-flex; align-items: center; justify-content: center;
++ min-width: 30px; min-height: 32px; border-radius: 9px;
++ color: var(--ovk-muted); text-decoration: none; font-size: 16px; line-height: 1;
++}
++.ex-branch-arrow:hover, .ex-branch-arrow:focus-visible { background: var(--ovk-panel-2); color: var(--ovk-text); }
++.ex-branch-arrow.is-off { opacity: .3; }
++.ex-branch-count { font-size: 12.5px; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
++
++@media (hover: hover) and (pointer: fine) {
++ .ex-acts { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
++ .ovt-turn:hover .ex-acts, .ovt-turn:focus-within .ex-acts { opacity: 1; pointer-events: auto; }
++}
++.ex-acts:has(.ex-menu[open]) { opacity: 1; pointer-events: auto; }
++
++/* On a touch screen the quiet actions are always there, and they are finger-sized. */
++@media (hover: none), (pointer: coarse) {
++ .ex-act { min-height: 44px; }
++ .ex-branch-arrow { min-width: 44px; min-height: 44px; }
++}
ddiff --git a/scripts/shoot-companions.js b/scripts/shoot-companions.js
nnew file mode 100644
iindex 0000000..5907e53
---- /dev/null
++++ b/scripts/shoot-companions.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * The design-review screenshots for the companions (Opus, 2026-11-06): the gallery, a character and a chat, at
++ * 390 and 1280, in dark and light.
++ *
++ * node scripts/shoot-companions.js [outDir]
++ *
++ * The shots go to a folder outside the repository (a temporary one by default): they are pictures of the site, not
++ * code, and they are never committed.
++ *
++ * It boots the real app on a temp database with a mock Network and a stand-in AI, seeds a few characters, and
++ * drives headless Chrome (the ov-phone-shots recipe). It checks the width (scrollWidth === viewport), the
++ * console, and the tap targets at 390 px, and says what it found.
++ */
++const path = require('path');
++const os = require('os');
++const { boot } = require('../test/helpers/boot');
++const { createFakeAi } = require('../test/helpers/ai');
++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
++
++const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-companions-shots'));
++const CHROME = '/usr/bin/google-chrome';
++
++const FORM = {
++ name: 'Ada Lovelace',
++ description: 'The first programmer. Writes algorithms as if they were music.',
++ personality: 'precise, curious, warm',
++ scenario: 'her study in London, 1843',
++ first_message: 'Good evening. I have been thinking about the engine all day. What shall we compute?',
++ example_dialogue: '<START>',
++ tags: 'analytical, history, helpful',
++ rating: 'everyone',
++};
++
++(async () => {
++ const ai = createFakeAi();
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++
++ // A few characters, so the gallery has a grid and the fold has something to count.
++ const people = [
++ { ...FORM, name: 'Ada Lovelace' },
++ { ...FORM, name: 'Alan Turing', description: 'A colleague in the history of computing.', tags: 'analytical, history' },
++ { ...FORM, name: 'Grace Hopper', description: 'A colleague in the history of computing.', tags: 'helpful, history' },
++ { ...FORM, name: 'Katherine Johnson', description: 'A colleague in the history of computing.', tags: 'analytical, helpful' },
++ { ...FORM, name: 'Radia Perlman', description: 'A colleague in the history of computing.', tags: 'analytical' },
++ { ...FORM, name: 'Margaret Hamilton', description: 'A colleague in the history of computing.', tags: 'helpful' },
++ { ...FORM, name: 'Barbara Liskov', description: 'A colleague in the history of computing.', tags: 'analytical, helpful' },
++ { ...FORM, name: 'Annie Easley', description: 'A colleague in the history of computing.', tags: 'history' },
++ { ...FORM, name: 'Adele Goldberg', description: 'A colleague in the history of computing.', tags: 'analytical' },
++ { ...FORM, name: 'Karen Sparck Jones', description: 'A colleague in the history of computing.', tags: 'helpful' },
++ { ...FORM, name: 'Frances Allen', description: 'A colleague in the history of computing.', tags: 'history' },
++ { ...FORM, name: 'Sophie Wilson', description: 'A colleague in the history of computing.', tags: 'analytical' },
++ { ...FORM, name: 'Evelyn Boyd Granville', description: 'A colleague in the history of computing.', tags: 'helpful' },
++ { ...FORM, name: 'Mary Jackson', description: 'A colleague in the history of computing.', tags: 'analytical' },
++ { ...FORM, name: 'Lynn Conway', description: 'A colleague in the history of computing.', tags: 'history' },
++ ];
++ const made = [];
++ for (const form of people) {
++ const r = await t.get('/companions/new', { as: alice, form });
++ if (r.status !== 303) throw new Error('the card was not made: ' + r.status + ' ' + r.text.slice(0, 300));
++ made.push(r.headers.get('location'));
++ }
++ // The first card is public, so the gallery has one.
++ const cards = await t.ctx.characters.listByOwner(alice.subject);
++ for (const c of cards) {
++ await t.get('/companions/' + c.id + '/share', { as: alice, form: { audience: 'public', allow_remix: '1' } });
++ }
++
++ // A chat, with a turn and an answer, so the receipt and the panel have something in them.
++ const talk = await t.get(made[0] + '/talk', { as: alice, method: 'POST' });
++ const chatUrl = talk.headers.get('location');
++ await t.get(chatUrl + '/send', {
++ as: alice,
++ method: 'POST',
++ form: { text: 'Good evening! Teach me about the notes.' },
++ });
++ await t.get(chatUrl + '/send', {
++ as: alice,
++ method: 'POST',
++ form: { text: 'What is the first algorithm?' },
++ });
++
++ const cookie = t.signIn(alice);
++ const base = t.base;
++ const shots = [
++ { name: 'gallery', path: '/companions' },
++ { name: 'character', path: made[0] },
++ { name: 'chat', path: chatUrl },
++ ];
++
++ const browser = await chromium.launch({ executablePath: CHROME });
++ const problems = [];
++ for (const shot of shots) {
++ for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) {
++ const ctx = await browser.newContext({
++ viewport: { width: w, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2,
++ isMobile: w < 700, hasTouch: w < 700,
++ });
++ const url = new URL(base);
++ await ctx.addCookies([
++ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_theme_id', value: scheme === 'light' ? 'daylight' : 'vibe', domain: url.hostname, path: '/' },
++ ]);
++ const pg = await ctx.newPage();
++ // Only this site's own errors count. The Network navbar (openvibe.network) is loaded from another
++ // origin on purpose; its CORS refusals and its 401 on the themes call are not ours.
++ const errs = [];
++ const ours = (text) => !/openvibe\.network|CORS|Failed to load resource/.test(String(text));
++ pg.on('pageerror', (e) => { if (ours(e.message)) errs.push(e.message); });
++ pg.on('console', (m) => { if (m.type() === 'error' && ours(m.text())) errs.push(m.text()); });
++ await pg.goto(base + shot.path, { waitUntil: 'load' });
++ await pg.waitForTimeout(600);
++ const docW = await pg.evaluate(() => document.documentElement.scrollWidth);
++ if (docW !== w) problems.push(shot.name + ' ' + w + ' ' + scheme + ': scrollWidth ' + docW + ' > ' + w);
++ if (errs.length) problems.push(shot.name + ' ' + w + ' ' + scheme + ': console: ' + errs.join(' | '));
++ if (w === 390) {
++ const small = await pg.evaluate(() => {
++ const out = [];
++ for (const el of document.querySelectorAll('a, button, input, select, textarea')) {
++ // The Network navbar is another site's chrome; only this page's own targets count.
++ // The Network navbar and the shared footer are another site's chrome (openvibe-shared);
++ // only this page's own targets count.
++ if (el.closest('nav, .ovnav, .openvibe-navbar, .skip, .ovf-legal-inline, .ovf-col--brand, [data-network-navbar]')) continue;
++ const r = el.getBoundingClientRect();
++ if (!r.width || !r.height) continue;
++ // A visually hidden control (the styled select's native proxy, a screen-reader only
++ // input) is not a finger target: the visible control next to it is.
++ const st = getComputedStyle(el);
++ if (st.opacity === '0' || st.visibility === 'hidden' || el.closest('.ovk-sr, .sr-only, .visually-hidden')) continue;
++ if (r.height < 44) out.push(el.tagName + '.' + (el.className || '') + ' [' + String(el.textContent || '').trim().slice(0, 20) + '] parent=' + (el.parentElement ? el.parentElement.tagName + '.' + (el.parentElement.className || '') : '') + ' h=' + Math.round(r.height));
++ }
++ return out.slice(0, 8);
++ });
++ if (small.length) problems.push(shot.name + ' 390 ' + scheme + ': small tap targets: ' + small.join(', '));
++ }
++ await pg.screenshot({ path: path.join(OUT, shot.name + '-' + w + '-' + scheme + '.png'), fullPage: true });
++ await pg.close();
++ await ctx.close();
++ }
++ }
++ await browser.close();
++ process.stdout.write((problems.length ? 'PROBLEMS:' + String.fromCharCode(10) + problems.join(String.fromCharCode(10)) : 'clean: no overflow, no console errors, tap targets ok') + String.fromCharCode(10));
++ process.exit(problems.length ? 1 : 0);
++})();
ddiff --git a/server/ai.js b/server/ai.js
iindex 8ca6449..25f23ba 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -79,7 +79,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
}
/** One completion, as the person: the run's quota and free allowance are theirs (on_behalf_of). */
-- async function chat({ model, messages, temperature = null, maxTokens = 1024, subject, signal = null }) {
++ async function chat({ model, messages, temperature = null, maxTokens = 1024, subject, signal = null, policy = null, preset = null, max_cost_usd = null }) {
if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
const token = await tokens.getToken();
const body = {
@@@ -89,9 +89,14 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
...(model ? { model: String(model).slice(0, 256) } : {}),
...(temperature != null ? { temperature: Number(temperature) } : {}),
...(maxTokens ? { max_tokens: Math.max(1, Math.min(16000, Number(maxTokens) || 1024)) } : {}),
++ ...(max_cost_usd != null ? { max_cost_usd: Number(max_cost_usd) } : {}),
},
...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),
-- options: { cache: 'none' },
++ options: {
++ cache: 'none',
++ ...(policy ? { policy: String(policy).slice(0, 40) } : {}),
++ ...(preset ? { preset: { name: String(preset).slice(0, 120), ...(policy ? { policy: String(policy).slice(0, 40) } : {}) } } : {}),
++ },
};
const res = await fetchImpl(`${config.ai.url}/api/v1/runs?wait=${config.ai.waitMs}`, {
method: 'POST',
ddiff --git a/server/app.js b/server/app.js
iindex 38a1ca1..bd07c61 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -30,7 +30,11 @@ const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
const { createOpenApi } = require('./openapi');
const { createChatStore, createAdultStore } = require('./chat/store');
++const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
++const { createSharing } = require('./companions/sharing');
++const { createAdultAccount } = require('./companions/adult');
const { createPageRoutes } = require('./http/pages');
++const { createCompanionRoutes } = require('./http/companions');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { raw } = require('./render/html');
@@@ -53,6 +57,8 @@ async function createApp(opts = {}) {
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const s = opts.store || await openStore(config, { now: opts.now, log });
++ // The sharing system's own DDL (openvibe-sdk/share). The migration role creates it in production; the runtime
++
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
@@@ -60,6 +66,12 @@ async function createApp(opts = {}) {
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
const chats = createChatStore(s);
const adult = createAdultStore(s);
++ // Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
++ const characters = createCharacterStore(s);
++ const companionChats = createCompanionChatStore(s);
++ const sharing = createSharing({ db: s.db, config, fetchImpl, log });
++ // The 18+ confirmation, read from the account on Network (Network PR #587).
++ const adultAccount = createAdultAccount({ config, fetchImpl, log, now: s.now });
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@@ -69,7 +81,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, openapi, chats, adult, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, ai, openapi, chats, adult, adultAccount, characters, companionChats, sharing, accountData, accountSend, log };
const app = express();
app.disable('x-powered-by');
@@@ -176,6 +188,8 @@ async function createApp(opts = {}) {
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
app.use(agent);
}
++ // The companions routes come before the pages router: /companions, /c/... and /talk/...
++ app.use(createCompanionRoutes(ctx));
app.use(createPageRoutes(ctx));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/companions/adult.js b/server/companions/adult.js
nnew file mode 100644
iindex 0000000..0a911e8
---- /dev/null
++++ b/server/companions/adult.js
@@@ -0,0 +1,132 @@
++'use strict';
++
++/**
++ * The 18+ confirmation, read from the account (OpenVibe.Network).
++ *
++ * The design says "signed in with Network's 18+ confirmation". Network owns the account and the confirmation:
++ * the person ticks "I am 18 or older" in their account settings and Network records it on the account row
++ * (Network PR #587, server/identity/adult.js). OpenVibe.AI reads the same flag for the uncensored tier
++ * (OpenVibe.AI server/policy/adult-status.js), so the adults' tier and the 18+ characters read one flag.
++ *
++ * Two ways to ask, both Network's own:
++ *
++ * - GET /api/v1/me/adult with the person's own token. The token is the session Exchange already
++ * holds (the one the navbar and every other service call use), so this is
++ * "is this signed-in person confirmed", asked of the account owner.
++ * - GET /internal/identity/adult with a service token carrying identity.subject.resolve. This is the
++ * route AI uses. Network grants Exchange search.document.write,
++ * events.subscription.manage and ai.run.create — not
++ * identity.subject.resolve — so a service-token read of the flag is refused
++ * today. The person's own token needs no grant, so that is the route taken
++ * here; the service-token route stays the fallback for the day Network
++ * grants it.
++ *
++ * The answer is cached for five minutes per person, so a burst of requests costs one call. A failure is never
++ * cached, and it never grants: an unreadable flag is not a yes.
++ */
++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CACHE_MS = 5 * 60 * 1000;
++const MAX_CACHE = 5000;
++
++function createAdultAccount({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() } = {}) {
++ const base = String((config && config.networkUrl) || '').replace(/\/+$/, '');
++ const cache = new Map(); // subject -> { adult, at }
++ let tokens = null;
++
++ function tokenClient() {
++ if (!tokens) {
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ tokens = createServiceTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: (config && config.oauth && config.oauth.clientId) || 'exchange',
++ clientSecret: (config && config.oauth && config.oauth.clientSecret) || '',
++ fetch: fetchImpl,
++ });
++ }
++ return tokens;
++ }
++
++ /** The person's own answer, asked of Network with the session token this site already holds. */
++ async function askWithToken(subject, token) {
++ if (!token) return null;
++ const res = await fetchImpl(`${base}/api/v1/me/adult`, {
++ method: 'GET',
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
++ signal: AbortSignal.timeout(5000),
++ });
++ if (res.status === 200) {
++ const body = await res.json().catch(() => null);
++ return { adult: Boolean(body && body.adult === true) };
++ }
++ if (res.status === 404) return { adult: false }; // the route is not deployed yet
++ if (res.status === 401 || res.status === 403) return { adult: false };
++ throw new Error(`Network answered ${res.status}`);
++ }
++
++ /** The service-token route, for the day Network grants Exchange identity.subject.resolve. */
++ async function askWithServiceToken(subject) {
++ const token = await tokenClient().getToken({ audience: 'openvibe.network' });
++ const res = await fetchImpl(`${base}/internal/identity/adult?subject_id=${encodeURIComponent(subject)}`, {
++ method: 'GET',
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
++ signal: AbortSignal.timeout(5000),
++ });
++ if (res.status === 200) {
++ const body = await res.json().catch(() => null);
++ return { adult: Boolean(body && body.adult === true) };
++ }
++ if (res.status === 404) return { adult: false };
++ throw new Error(`Network answered ${res.status}`);
++ }
++
++ /**
++ * Has this person confirmed, on their account, that they are 18 or older?
++ * Fails closed: an unreadable flag is never a yes.
++ */
++ async function adultFor(subject, token = null) {
++ const id = String(subject || '');
++ if (!USR.test(id)) return false;
++ const hit = cache.get(id);
++ if (hit && now() - hit.at < CACHE_MS) return hit.adult;
++ let out = null;
++ try {
++ out = await askWithToken(id, token);
++ } catch (err) {
++ log.warn('[Companions] the 18+ confirmation could not be read:', String((err && err.message) || err).slice(0, 160));
++ }
++ if (!out) {
++ try { out = await askWithServiceToken(id); }
++ catch (err) {
++ log.warn('[Companions] the 18+ confirmation could not be read from the account service:', String((err && err.message) || err).slice(0, 160));
++ }
++ }
++ if (!out) return false; // a failure is never cached, and never grants
++ if (cache.size >= MAX_CACHE) cache.clear();
++ cache.set(id, { adult: out.adult, at: now() });
++ return out.adult;
++ }
++
++ /**
++ * Record the confirmation on the account, so the flag is one flag: Network keeps it (Network PR #587),
++ * and OpenVibe.AI and the companions read the same answer.
++ */
++ async function confirm(token) {
++ if (!token) throw new Error('no session token: the confirmation could not be recorded');
++ const res = await fetchImpl(`${base}/api/v1/me/adult`, {
++ method: 'PUT',
++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ confirm: true }),
++ signal: AbortSignal.timeout(5000),
++ });
++ if (!res.ok) throw new Error(`Network answered ${res.status}`);
++ cache.clear();
++ return true;
++ }
++
++ function forget(subject) { cache.delete(String(subject || '')); }
++ function clear() { cache.clear(); }
++
++ return { adultFor, confirm, forget, clear };
++}
++
++module.exports = { createAdultAccount, USR, CACHE_MS };
ddiff --git a/server/companions/codec.js b/server/companions/codec.js
nnew file mode 100644
iindex 0000000..33d8b6b
---- /dev/null
++++ b/server/companions/codec.js
@@@ -0,0 +1,184 @@
++'use strict';
++
++/**
++ * Character Card V2 and V3, in and out (Opus's design, 2026-11-06): people bring their SillyTavern, Chub and
++ * Janitor characters in, and leave with them.
++ *
++ * The open format:
++ * - plain JSON: { "spec": "chara_card_v2" | "chara_card_v3", "spec_version": "2.0" | "3.0", "data": { … } }
++ * - PNG: the same JSON, base64-encoded, in a tEXt chunk named "chara" (the keyword SillyTavern writes;
++ * "ccv2" and "ccv3" are read too, because older exports use them).
++ *
++ * The PNG is written by hand here — a chunk reader, a chunk writer and CRC-32 — so no image library is
++ * needed and the picture itself is never re-encoded: the bytes are copied through and one chunk is added.
++ */
++
++const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
++
++// ── CRC-32 (PNG's) ──────────────────────────────────────────────────────────
++const CRC_TABLE = (() => {
++ const table = new Int32Array(256);
++ for (let n = 0; n < 256; n++) {
++ let c = n;
++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
++ table[n] = c;
++ }
++ return table;
++})();
++
++function crc32(buf) {
++ let c = 0xffffffff;
++ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
++ return (c ^ 0xffffffff) >>> 0;
++}
++
++/** One PNG chunk: length, type, data, crc. */
++function chunk(type, data) {
++ const len = Buffer.alloc(4);
++ len.writeUInt32BE(data.length, 0);
++ const typeBuf = Buffer.from(type, 'ascii');
++ const crc = Buffer.alloc(4);
++ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
++ return Buffer.concat([len, typeBuf, data, crc]);
++}
++
++/** The tEXt chunks of a PNG, as { keyword, text }. */
++function readTexts(buffer) {
++ const out = [];
++ if (!buffer || buffer.length < 8 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return out;
++ let at = 8;
++ while (at + 8 <= buffer.length) {
++ const len = buffer.readUInt32BE(at);
++ const type = buffer.toString('ascii', at + 4, at + 8);
++ const data = buffer.subarray(at + 8, at + 8 + len);
++ if (type === 'tEXt') {
++ const split = data.indexOf(0);
++ if (split > 0) out.push({ keyword: data.toString('latin1', 0, split), text: data.toString('latin1', split + 1) });
++ } else if (type === 'zTXt') {
++ const split = data.indexOf(0);
++ if (split > 0) out.push({ keyword: data.toString('latin1', 0, split), text: '' });
++ } else if (type === 'IEND') break;
++ at += 12 + len;
++ }
++ return out;
++}
++
++/** A copy of the PNG with one tEXt chunk added just before IEND. The picture bytes are untouched. */
++function writeText(buffer, keyword, text) {
++ if (!buffer || buffer.length < 8 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('not a PNG');
++ const data = Buffer.concat([Buffer.from(keyword, 'latin1'), Buffer.from([0]), Buffer.from(text, 'latin1')]);
++ const tEXt = chunk('tEXt', data);
++ let at = 8;
++ while (at + 8 <= buffer.length) {
++ const len = buffer.readUInt32BE(at);
++ const type = buffer.toString('ascii', at + 4, at + 8);
++ if (type === 'IEND') {
++ return Buffer.concat([buffer.subarray(0, at), tEXt, buffer.subarray(at)]);
++ }
++ at += 12 + len;
++ }
++ throw new Error('the PNG has no IEND chunk');
++}
++
++const KEYWORDS = ['chara', 'ccv2', 'ccv3', 'character'];
++const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
++const unb64 = (s) => Buffer.from(String(s).replace(/\s+/g, ''), 'base64').toString('utf8');
++
++/** The card's own fields, in the open format's words. */
++function toSpec(card, spec = 'v3') {
++ const data = {
++ name: String(card.name || '').slice(0, 80),
++ description: String(card.description || ''),
++ personality: String(card.personality || ''),
++ scenario: String(card.scenario || ''),
++ first_mes: String(card.first_message || ''),
++ mes_example: String(card.example_dialogue || ''),
++ creator_notes: String(card.creator_notes || ''),
++ system_prompt: '',
++ post_history_instructions: '',
++ alternate_greetings: [],
++ tags: Array.isArray(card.tags) ? card.tags.map(String) : [],
++ creator: String(card.creator || ''),
++ character_version: String(card.character_version || '1'),
++ extensions: {},
++ };
++ if (card.rating === 'adult') data.extensions = { ...(data.extensions || {}), openvibe: { rating: 'adult', age: card.age ?? null } };
++ if (spec === 'v3') {
++ data.assets = card.avatar_url ? [{ type: 'image', name: 'avatar', uri: String(card.avatar_url) }] : [];
++ data.group_only_greetings = [];
++ return { spec: 'chara_card_v3', spec_version: '3.0', data };
++ }
++ return { spec: 'chara_card_v2', spec_version: '2.0', data };
++}
++
++/** The open format's words back into a card. V2 and V3 differ only in the extra V3 fields, so both read here. */
++function fromSpec(json) {
++ if (!json || typeof json !== 'object') throw new Error('the card is not JSON');
++ const data = json.data && typeof json.data === 'object' ? json.data : json;
++ if (!data || typeof data !== 'object') throw new Error('the card has no data');
++ const ext = (data.extensions && data.extensions.openvibe) || {};
++ const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t).trim()).filter(Boolean) : [];
++ const out = {
++ name: String(data.name || '').trim().slice(0, 80),
++ description: String(data.description || ''),
++ personality: String(data.personality || ''),
++ scenario: String(data.scenario || ''),
++ first_message: String(data.first_mes || ''),
++ example_dialogue: String(data.mes_example || ''),
++ creator_notes: String(data.creator_notes || ''),
++ tags: [...new Set(tags)].slice(0, 20),
++ creator: String(data.creator || ''),
++ character_version: String(data.character_version || '1'),
++ rating: ext.rating === 'adult' ? 'adult' : 'everyone',
++ age: ext.age != null ? Number(ext.age) : null,
++ };
++ if (!out.name) throw new Error('the card has no name');
++ return out;
++}
++
++/**
++ * Read a card from bytes: a PNG with the JSON in a tEXt chunk, or plain JSON.
++ * Returns { card, spec } and throws with a plain reason when the bytes are not a card.
++ */
++function importCard(buffer, { filename = '' } = {}) {
++ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
++ const isPng = buf.length > 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE);
++ if (isPng) {
++ const texts = readTexts(buf);
++ const hit = texts.find((t) => KEYWORDS.includes(t.keyword.toLowerCase()) && t.text);
++ if (!hit) throw new Error('this PNG carries no character card');
++ let json;
++ try { json = JSON.parse(unb64(hit.text)); }
++ catch (e) { throw new Error('the card in this PNG is not readable JSON'); }
++ return { card: fromSpec(json), spec: String(json.spec || 'chara_card_v2') };
++ }
++ let json;
++ try { json = JSON.parse(buf.toString('utf8')); }
++ catch (e) { throw new Error(filename ? `${filename} is not a character card` : 'this is not a character card'); }
++ return { card: fromSpec(json), spec: String(json.spec || 'chara_card_v2') };
++}
++
++/**
++ * Write a card out. format: 'json' or 'png'. A PNG needs a picture to carry the card: the avatar's bytes,
++ * or a 1x1 transparent PNG when there is none.
++ */
++function exportCard(card, { format = 'json', spec = 'v3', image = null } = {}) {
++ const json = toSpec(card, spec);
++ const text = JSON.stringify(json, null, 2);
++ if (format === 'png') {
++ const png = image && Buffer.isBuffer(image) && image.length > 8 ? image : BLANK_PNG;
++ const withCard = writeText(png, 'chara', b64(text));
++ return { contentType: 'image/png', buffer: withCard, filename: `${slugOf(card.name)}.png` };
++ }
++ return { contentType: 'application/json; charset=utf-8', buffer: Buffer.from(text, 'utf8'), filename: `${slugOf(card.name)}.json` };
++}
++
++const slugOf = (name) => String(name || 'character').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'character';
++
++/** A 1x1 transparent PNG, for a card with no avatar. */
++const BLANK_PNG = Buffer.from(
++ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
++ 'base64'
++);
++
++module.exports = { importCard, exportCard, toSpec, fromSpec, readTexts, writeText, crc32, slugOf, PNG_SIGNATURE };
ddiff --git a/server/companions/floor.js b/server/companions/floor.js
nnew file mode 100644
iindex 0000000..fbcebec
---- /dev/null
++++ b/server/companions/floor.js
@@@ -0,0 +1,141 @@
++'use strict';
++
++/**
++ * The hard floor on OpenVibe.Exchange's companions (Opus's design, 2026-11-06).
++ *
++ * The design says "reuse, never copy: the house classifiers". Two of them are not published as a package:
++ *
++ * - OpenVibe.AI's server/policy/floor.js holds the three hard rules (minors, non-consensual, mass harm).
++ * It is an application module of that service, not a library, so there is nothing to import here.
++ * - OpenVibe.Live's server/ai/explicit.js holds the three VIS-6 categories (fine, sex_acts, minor_sexual).
++ * OpenVibe.SDK publishes the same rules as `openvibe-sdk/explicit` (isExplicit) — that one IS imported,
++ * below, and it is the copy the house keeps in step with Live.
++ *
++ * So: the SDK's published classifier is imported, and the two unpublished rule sets are carried here with
++ * their source named in the header of each block. When AI or Live publish theirs, this file becomes a call.
++ *
++ * The floor is checked in three places: on the card at save, on the person's input, and on the model's output.
++ * A breach ends the chat and the account is reviewed.
++ *
++ * floor.check(texts) → { ok: true } | { ok: false, rule, label, detail }
++ * floor.checkCard(card) → { ok: true } | { ok: false, rule, label, detail }
++ * floor.classifyText(texts) → { category: 'fine' | 'sex_acts' | 'minor_sexual', reason }
++ */
++
++// ── OpenVibe.SDK's published copy of Live's rules (openvibe-sdk/explicit, v0.85.0) ──
++const { isExplicit: sdkIsExplicit } = require('openvibe-sdk/explicit');
++
++/**
++ * OpenVibe.Live, server/ai/explicit.js (2026-10-31), carried here because Live does not publish this module.
++ * VIS-6's three categories: fine, sex_acts, minor_sexual. Describing people is fine; nudity is fine; a child
++ * together with nudity is the hard line.
++ */
++const EXPLICIT = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|adult\s+(?:films?|videos?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*)\b/i;
++const LIVE_MINOR = /\b(?:child(?:ren)?|kids?|minors?|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage)\b/i;
++const LIVE_NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;
++const CATEGORIES = Object.freeze(['fine', 'sex_acts', 'minor_sexual']);
++
++/**
++ * OpenVibe.AI, server/policy/floor.js (2026-11-06), carried here because AI does not publish this module.
++ * The three rules, in the order they are checked. The patterns are deliberately narrow so an ordinary
++ * question ("how do I kill a stuck process") is never refused.
++ */
++const MINOR = /\b(?:child(?:ren)?|kids?|minors?|minor|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage|pre-?teen(?:s)?|schoolboy|schoolgirl|loli|shota)\b/i;
++const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|boobs?|nipples?|erotic(?:a)?|orgasm|cunnilingus|fellatio)\b/i;
++const CSAM = /\b(?:child\s?(?:porn|porno|abuse|sexual)|cp\s(?:content|video|series)\b|kiddie\s?(?:porn|porno)|pedophile|pedophilia|paedophile|paedophilia)\b/i;
++const NONCONSENSUAL = /\b(?:rape|raped|rapes|raping|rapist|sexual assault|sexual abuse|molest(?:ed|ing|s)?|incest|statutory rape|non-?consensual|without (?:her|his|their|my) consent|she (?:said|said no|didn't consent|did not consent)|he (?:said no|didn't consent|did not consent)|they (?:said no|didn't consent|did not consent)|took advantage of (?:her|him|them)|she was (?:drugged|drunk|asleep|unconscious)|drugged (?:her|him|them)|unconscious (?:sex|woman|girl|man)|forced (?:sex|himself|herself|them)|coerc(?:e|ed|ing|ion)|groom(?:ed|ing)?|traffick(?:ed|ing)?|deepfake(?:s|d)?|revenge porn|nude photos? of (?:her|him|them)|sexual(?:ly)? explicit (?:photos?|pictures?|images?|videos?) of (?:her|him|them))\b/i;
++const MASS_HARM = /\b(?:nerve agent|sarin|vx gas|tabun|soman|mustard gas|chlorine gas|anthrax|ricin|botulinum|cyanide|nerve gas|bioweapon|biological weapon|chemical weapon|dirty bomb|nuclear (?:bomb|device|weapon)|radiological (?:bomb|device|weapon)|weapons? of mass destruction|\bwmd\b|germ warfare|biological warfare|pathogen (?:strain|weapon)|weaponized? (?:virus|pathogen|bacillus))\b/i;
++const WEAPON_INTENT = /\b(?:(?:make|build|construct|synthesi[sz]e|manufactur(?:e|ing)|assembl\w+|acquire|obtain|buy|purchase|deploy|detonat\w+|ignit\w+|use|plant|rig)\w*\b[^.?!]{0,60}\b(?:bomb|explosive|ied\b|pipe bomb|dirty bomb|grenade|molotov|napalm|thermite|shrapnel|untraceable (?:gun|firearm)|ghost gun|suppressor|silencer)|(?:bomb|explosive|ied\b|pipe bomb|grenade|molotov|napalm|thermite)\b[^.?!]{0,60}\b(?:make|build|construct|synthesi[sz]e|manufactur\w+|how to|recipe|instructions?|detonat\w+|ignit\w+))/i;
++const ATTACK_INTENT = /\b(?:(?:how (?:do|can|would) (?:i|we|you|one))\s+[^.?!]{0,40}\b(?:kill|murder|assassinate|poison|hurt|attack|stab|shoot)\b[^.?!]{0,40}\b(?:him|her|them|someone|somebody|people|my \w+|a person|people I|people at)|(?:kill|murder|assassinate|poison|stab|shoot)\s+(?:him|her|them|someone|somebody|people|my \w+|a person)|mass shooting|school shooting|terrorist attack|suicide attack|car bomb|truck bomb)\b/i;
++
++/** The three rules, in the order they are checked. */
++const RULES = [
++ { rule: 'minors', label: 'sexual content involving minors', test: (t) => CSAM.test(t) || (MINOR.test(t) && NUDE.test(t)) },
++ { rule: 'non_consensual', label: 'non-consensual sexual content of real people', test: (t) => NONCONSENSUAL.test(t) },
++ { rule: 'mass_harm', label: 'weapons of mass harm or attacks on others', test: (t) => MASS_HARM.test(t) || WEAPON_INTENT.test(t) || ATTACK_INTENT.test(t) },
++];
++
++/** The version of the rules, written on every refusal and in the changelog. */
++const VERSION = 'floor-2026-11-06';
++
++/** The floor on a text: { ok: true } or { ok: false, rule, label, detail }. */
++function check(texts) {
++ const list = (Array.isArray(texts) ? texts : [texts]).filter((t) => t != null && String(t).trim());
++ for (const t of list) {
++ const text = String(t);
++ for (const r of RULES) if (r.test(text)) {
++ return { ok: false, rule: r.rule, label: r.label, version: VERSION, detail: `this breaks the hard floor (${r.label})` };
++ }
++ }
++ return { ok: true, version: VERSION };
++}
++
++/** The floor on the way in and on the way out: one text, a list of texts, or a request's messages. */
++function checkRequest(input) {
++ if (typeof input === 'string') return check([input]);
++ if (Array.isArray(input)) return check(input);
++ const texts = [];
++ if (input && typeof input.system === 'string') texts.push(input.system);
++ for (const m of (input && input.messages) || []) {
++ if (!m) continue;
++ if (typeof m.content === 'string') texts.push(m.content);
++ else if (Array.isArray(m.content)) for (const p of m.content) if (p && p.text) texts.push(String(p.text));
++ }
++ return check(texts);
++}
++
++/** Live's classifier, in the three categories VIS-6 uses. */
++function classifyText(...texts) {
++ const all = texts.filter((t) => t != null).map(String).join('\n').trim();
++ if (!all) return { category: 'fine', reason: 'there is no text to judge' };
++ if (LIVE_MINOR.test(all) && LIVE_NUDE.test(all)) return { category: 'minor_sexual', reason: 'the text puts a child together with nudity' };
++ if (EXPLICIT.test(all)) return { category: 'sex_acts', reason: 'the text describes sex acts or pornography' };
++ return { category: 'fine', reason: 'the text describes neither sex acts nor a child with nudity' };
++}
++
++/** True when the text is not fine under Live's classifier (the SDK's published copy answers the same). */
++function isExplicit(...texts) {
++ return classifyText(...texts).category !== 'fine' || sdkIsExplicit(...texts);
++}
++
++/** The card's own words: every field a person wrote, read as one text. */
++function cardTexts(card) {
++ if (!card) return [];
++ const fields = ['name', 'description', 'personality', 'scenario', 'first_message', 'example_dialogue'];
++ return fields.map((f) => String(card[f] || '')).filter((t) => t.trim());
++}
++
++/**
++ * The floor on a card at save. Two extra rules of the design:
++ * - a card rated 18+ whose stated age is under 18 is refused at save;
++ * - "aged-up" framing is refused like any other minor content (a card that says a child is older is a
++ * minor card: the age field is not the only place an age is written).
++ */
++function checkCard(card) {
++ const texts = cardTexts(card);
++ const floor = check(texts);
++ if (!floor.ok) return floor;
++ const rating = String((card && card.rating) || 'everyone');
++ const age = card && card.age != null && card.age !== '' ? Number(card.age) : null;
++ if (rating === 'adult' && age != null && Number.isFinite(age) && age < 18) {
++ return {
++ ok: false, rule: 'minors', label: 'sexual content involving minors', version: VERSION,
++ detail: 'a card rated 18+ may not state an age under 18',
++ };
++ }
++ const live = classifyText(...texts);
++ if (live.category === 'minor_sexual') {
++ return { ok: false, rule: 'minors', label: 'sexual content involving minors', version: VERSION, detail: live.reason };
++ }
++ return { ok: true, version: VERSION };
++}
++
++module.exports = {
++ VERSION, RULES, CATEGORIES, check, checkRequest, checkCard, classifyText, isExplicit,
++ cardTexts, EXPLICIT, MINOR, NUDE, CSAM, NONCONSENSUAL, MASS_HARM, WEAPON_INTENT, ATTACK_INTENT,
++ SOURCES: [
++ 'OpenVibe.AI server/policy/floor.js (2026-11-06) — the three hard rules',
++ 'OpenVibe.Live server/ai/explicit.js (2026-10-31) — VIS-6 categories fine / sex_acts / minor_sexual',
++ 'openvibe-sdk/explicit (v0.85.0) — the published copy of Live rules, imported',
++ ],
++};
ddiff --git a/server/companions/sharing.js b/server/companions/sharing.js
nnew file mode 100644
iindex 0000000..9e88ee2
---- /dev/null
++++ b/server/companions/sharing.js
@@@ -0,0 +1,160 @@
++'use strict';
++
++/**
++ * Sharing for character cards (Opus's design, 2026-11-06), through openvibe-sdk/share.
++ *
++ * The SDK owns the model and the rules: the shares row next to each card, the one access check (canView),
++ * members, access requests, transfers, the append-only log and the delivery queue. This file is the wiring
++ * a service supplies, and it is the same wiring OpenVibe.Community gives its pastes:
++ *
++ * - network.json Network's identity lookups (@name to a subject, and a person's card for the page)
++ * - events.publish OpenVibe.Events, so share.* envelopes go out through the same queue
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps the card's own audience column in step, so an old reader that only knows the
++ * column fails closed (private stays private)
++ *
++ * The audiences: a card is private by default, unlisted (a link, not in the gallery) or public.
++ * The SDK speaks public, link, people and private; unlisted is its link.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'exchange';
++const KIND = 'character';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** Exchange's audience words to the SDK's, and back. */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++
++/** A card's share ref: { service: 'exchange', kind: 'character', id: <card id> }. */
++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
++
++/** Exchange's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '');
++ const tokens = config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: base + '/oauth/token',
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification, addressed by canonical subject. It never carries a card's text. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A character was shared with you', 'Open it on OpenVibe.Exchange.'],
++ 'share.access_requested': ['Someone asked for access to your character', 'Review the request on OpenVibe.Exchange.'],
++ 'share.transfer.requested': ['Someone wants to give you a character', 'Accept or decline it on OpenVibe.Exchange.'],
++ 'share.transfer.answered': ['A character transfer was answered', 'See what happened on OpenVibe.Exchange.'],
++ }[n.kind] || ['Sharing on OpenVibe.Exchange', 'There is something new for you.'];
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'exchange', url: n.ref && n.ref.id ? config.baseUrl + '/c/' + n.ref.id : config.baseUrl + '/companions',
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Companions] notification not sent:', err.message);
++ }
++ }
++
++ /** OpenVibe.Events. Exchange has no outbox of its own yet, so the envelope goes straight out. */
++ async function publish(envelope) {
++ if (!config.events.url) { log.warn('[Companions] no events URL configured; share envelope not sent'); return; }
++ const res = await fetchImpl(config.events.url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify(envelope),
++ signal: AbortSignal.timeout(8000),
++ });
++ if (!res.ok) throw new Error('Events answered ' + res.status);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onAudienceChange: async (ref, audience, tx) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ await tx.exec('UPDATE exchange_characters SET audience = $1, updated_at = now() WHERE id = $2', [FROM_SDK[audience] || 'private', ref.id]);
++ },
++ onOwnerChange: async (ref, from, to, tx) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const owner = to && to.type === 'user' ? to.id : null;
++ await tx.exec('UPDATE exchange_characters SET owner = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
++ },
++ });
++
++ const sv = (viewer) => shareViewer(viewer);
++ const at = (id) => refOf(id);
++
++ return {
++ shares,
++ SERVICE,
++ KIND,
++ AUDIENCES,
++ TO_SDK,
++ FROM_SDK,
++ refOf,
++ shareViewer,
++ networkJson,
++ /** The SDK's own DDL, run by the migration role (server/db.js). */
++ migrate: async () => await shares.migrate(),
++ /** Register a card with the sharing system, private until its owner says otherwise. */
++ register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
++ owner_subject: { type: 'user', id: String(viewer.subject) },
++ audience: TO_SDK[audience] || 'private',
++ }),
++ canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
++ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),
++ get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
++ getWithRevision: async (viewer, id) => await shares.getWithRevision(sv(viewer), at(id)),
++ setAudience: async (viewer, id, audience) => await shares.setAudience(sv(viewer), at(id), TO_SDK[audience] || 'private'),
++ addMembers: async (viewer, id, list) => await shares.addMembers(sv(viewer), at(id), list),
++ removeMember: async (viewer, id, subject) => await shares.removeMember(sv(viewer), at(id), subject),
++ requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),
++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ /** Expire members and transfers, then send the queued events and notifications. */
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Companions] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Companions] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++module.exports = { createSharing, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
ddiff --git a/server/companions/store.js b/server/companions/store.js
nnew file mode 100644
iindex 0000000..0402858
---- /dev/null
++++ b/server/companions/store.js
@@@ -0,0 +1,262 @@
++'use strict';
++
++/**
++ * Character cards and one-character chats (Opus's design, 2026-11-06).
++ *
++ * Every read and write is scoped by the owner in the WHERE clause, not checked after the fact: a card id or a
++ * chat id from another account finds nothing. The gallery is the one public read, and it says public only.
++ */
++
++const SORTS = { new: 'new', week: 'week', remixed: 'remixed' };
++
++function createCharacterStore(s) {
++ const db = s.db;
++
++ const shape = (row) => {
++ if (!row) return null;
++ const t = parseJson(row.tags);
++ return { ...row, tags: Array.isArray(t) ? t : [] };
++ };
++
++ async function create({ owner, handle, name, ...rest }) {
++ const id = s.newId('exc');
++ let slug = slugify(rest.slug || name);
++ for (let n = 2; n < 20; n++) {
++ const taken = await db.prepare('SELECT 1 FROM exchange_characters WHERE handle = ? AND slug = ?').get(String(handle || '').toLowerCase(), slug);
++ if (!taken) break;
++ slug = slugify(rest.slug || name) + '-' + n;
++ }
++ await db.prepare('INSERT INTO exchange_characters (id, owner, handle, slug, name, description, personality, scenario, first_message, example_dialogue, avatar_url, rating, age, model, voice, audience, allow_remix, share_id, remix_of, original_author) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, owner, String(handle || '').slice(0, 64), slug, String(name).slice(0, 80),
++ text(rest.description), text(rest.personality), text(rest.scenario), text(rest.first_message),
++ text(rest.example_dialogue), rest.avatar_url ? String(rest.avatar_url) : null,
++ rest.rating === 'adult' ? 'adult' : 'everyone', ageOf(rest.age), rest.model ? String(rest.model).slice(0, 120) : null,
++ rest.voice ? String(rest.voice).slice(0, 120) : null, rest.audience || 'private', rest.allow_remix === false ? false : true,
++ rest.share_id ? String(rest.share_id) : null, rest.remix_of ? String(rest.remix_of) : null,
++ rest.original_author ? String(rest.original_author).slice(0, 64) : null);
++ await setTags(id, rest.tags);
++ return get(owner, id);
++ }
++
++ async function get(owner, id) {
++ const row = await db.prepare('SELECT * FROM exchange_characters WHERE id = ? AND owner = ?').get(String(id || ''), owner);
++ return shape(row);
++ }
++
++ /** The public read: a card by its page path. Anyone may look; the audience decides what they may see. */
++ async function bySlug(handle, slug) {
++ const row = await db.prepare('SELECT * FROM exchange_characters WHERE handle = ? AND slug = ?')
++ .get(String(handle || '').toLowerCase(), String(slug || '').toLowerCase());
++ return shape(row);
++ }
++
++ async function update(owner, id, rest) {
++ const fields = ['name', 'description', 'personality', 'scenario', 'first_message', 'example_dialogue',
++ 'avatar_url', 'rating', 'age', 'model', 'voice', 'audience', 'allow_remix', 'remix_of', 'original_author'];
++ const sets = [];
++ const values = [];
++ for (const f of fields) {
++ if (!(f in rest)) continue;
++ sets.push(f + ' = ?');
++ if (f === 'rating') values.push(rest[f] === 'adult' ? 'adult' : 'everyone');
++ else if (f === 'age') values.push(ageOf(rest[f]));
++ else if (f === 'allow_remix') values.push(rest[f] === false ? false : true);
++ else if (f === 'name') values.push(String(rest[f] || '').slice(0, 80));
++ else if (f === 'audience') values.push(['private', 'unlisted', 'public'].includes(rest[f]) ? rest[f] : 'private');
++ else if (f === 'avatar_url') values.push(rest[f] ? String(rest[f]) : null);
++ else if (f === 'model' || f === 'voice') values.push(rest[f] ? String(rest[f]).slice(0, 120) : null);
++ else if (f === 'remix_of') values.push(rest[f] ? String(rest[f]) : null);
++ else if (f === 'original_author') values.push(rest[f] ? String(rest[f]).slice(0, 64) : null);
++ else values.push(text(rest[f]));
++ }
++ if (sets.length) {
++ await db.prepare('UPDATE exchange_characters SET ' + sets.join(', ') + ', updated_at = now() WHERE id = ? AND owner = ?')
++ .run(...values, String(id), owner);
++ }
++ if ('tags' in rest) await setTags(String(id), rest.tags);
++ return get(owner, id);
++ }
++
++ async function setTags(id, tags) {
++ const list = [...new Set((Array.isArray(tags) ? tags : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean))].slice(0, 20);
++ await db.prepare('DELETE FROM exchange_character_tags WHERE character_id = ?').run(String(id));
++ await db.prepare('UPDATE exchange_characters SET tags = ? WHERE id = ?').run(JSON.stringify(list), String(id));
++ for (const tag of list) {
++ await db.prepare('INSERT INTO exchange_character_tags (character_id, tag) VALUES (?, ?) ON CONFLICT DO NOTHING').run(String(id), tag);
++ }
++ }
++
++ async function listByOwner(owner) {
++ const rows = await db.prepare('SELECT * FROM exchange_characters WHERE owner = ? ORDER BY updated_at DESC').all(owner);
++ return rows.map(shape);
++ }
++
++ async function allTags(limit = 40) {
++ const rows = await db.prepare('SELECT tag, COUNT(*) AS n FROM exchange_character_tags GROUP BY tag ORDER BY n DESC, tag LIMIT ?').all(limit);
++ return rows.map((r) => ({ tag: r.tag, n: Number(r.n) || 0 }));
++ }
++
++ /**
++ * The gallery. Only public cards, and only cards rated for everyone unless the person is signed in with the
++ * 18+ confirmation: an 18+ card never appears on a public list for anybody else.
++ */
++ async function gallery({ sort = 'new', q = '', tags = [], limit = 12, offset = 0, allowAdult = false } = {}) {
++ const where = ["c.audience = 'public'"];
++ const values = [];
++ if (!allowAdult) where.push("c.rating = 'everyone'");
++ if (q) {
++ where.push('(c.name ILIKE ? OR c.description ILIKE ? OR c.personality ILIKE ?)');
++ const like = '%' + String(q).slice(0, 80) + '%';
++ values.push(like, like, like);
++ }
++ for (const tag of tags.slice(0, 5)) {
++ where.push('EXISTS (SELECT 1 FROM exchange_character_tags t WHERE t.character_id = c.id AND t.tag = ?)');
++ values.push(String(tag).toLowerCase());
++ }
++ const order = sort === SORTS.week
++ ? 'week_views DESC, c.created_at DESC'
++ : sort === SORTS.remixed
++ ? 'remixes DESC, c.created_at DESC'
++ : 'c.created_at DESC';
++ const sql = 'SELECT c.*, (SELECT COUNT(*) FROM exchange_character_views v WHERE v.character_id = c.id AND v.viewed_at >= now() - interval \'7 days\') AS week_views, (SELECT COUNT(*) FROM exchange_characters r WHERE r.remix_of = c.id) AS remixes FROM exchange_characters c WHERE ' + where.join(' AND ') + ' ORDER BY ' + order + ' LIMIT ? OFFSET ?';
++ const rows = await db.prepare(sql).all(...values, Math.max(1, Math.min(60, limit)), Math.max(0, offset));
++ const count = await db.prepare('SELECT COUNT(*) AS n FROM exchange_characters c WHERE ' + where.join(' AND ')).get(...values);
++ return { items: rows.map(shape), total: Number(count && count.n) || 0 };
++ }
++
++ async function addView(characterId, viewer) {
++ await db.prepare('INSERT INTO exchange_character_views (character_id, viewer) VALUES (?, ?)').run(String(characterId), viewer || null);
++ }
++
++ async function countRemixes(characterId) {
++ const row = await db.prepare('SELECT COUNT(*) AS n FROM exchange_characters WHERE remix_of = ?').get(String(characterId));
++ return Number(row && row.n) || 0;
++ }
++
++ async function tagsOf(characterId) {
++ const rows = await db.prepare('SELECT tag FROM exchange_character_tags WHERE character_id = ? ORDER BY tag').all(String(characterId));
++ return rows.map((r) => r.tag);
++ }
++
++ return { create, get, bySlug, update, listByOwner, gallery, allTags, addView, countRemixes, tagsOf, setTags, SORTS };
++}
++
++/**
++ * One-character chats, with a branching tree. A turn is a group (turn_id) of user messages: editing a message
++ * adds a variant to that group, and regenerating adds another answer to it. Nothing is overwritten, so the
++ * tree keeps every branch and the arrows walk it.
++ */
++function createCompanionChatStore(s) {
++ const db = s.db;
++
++ async function create({ owner, characterId, title = 'New chat', settings = {} }) {
++ const id = s.newId('exc');
++ await db.prepare('INSERT INTO exchange_companion_chats (id, owner, character_id, title, model, preset, temperature, context_size, persona) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, owner, String(characterId), String(title).slice(0, 120) || 'New chat',
++ settings.model ? String(settings.model).slice(0, 120) : null, settings.preset ? String(settings.preset).slice(0, 120) : null,
++ settings.temperature == null ? null : Number(settings.temperature),
++ settings.context_size == null ? null : Number(settings.context_size),
++ settings.persona ? String(settings.persona).slice(0, 2000) : null);
++ return get(owner, id);
++ }
++
++ async function get(owner, id) {
++ const row = await db.prepare('SELECT * FROM exchange_companion_chats WHERE id = ? AND owner = ?').get(String(id || ''), owner);
++ return row || null;
++ }
++
++ async function list(owner, limit = 60) {
++ const rows = await db.prepare('SELECT c.*, ch.name AS character_name FROM exchange_companion_chats c JOIN exchange_characters ch ON ch.id = c.character_id WHERE c.owner = ? ORDER BY c.updated_at DESC LIMIT ?').all(owner, Math.max(1, Math.min(200, limit)));
++ return rows;
++ }
++
++ async function messages(chatId) {
++ const rows = await db.prepare('SELECT * FROM exchange_companion_messages WHERE chat_id = ? ORDER BY id').all(String(chatId));
++ return rows.map((r) => ({ ...r, meta: parseJson(r.meta) }));
++ }
++
++ async function append({ owner, chatId, role, content = null, turnId, variant = 0, parentId = null, model = null, meta = {} }) {
++ const id = s.newId('exm');
++ await db.prepare('INSERT INTO exchange_companion_messages (id, chat_id, owner, role, content, turn_id, variant, parent_id, model, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, String(chatId), owner, role, content == null ? null : String(content), String(turnId), Number(variant) || 0,
++ parentId ? String(parentId) : null, model ? String(model) : null, JSON.stringify(meta || {}));
++ await db.prepare('UPDATE exchange_companion_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(String(chatId), owner);
++ return id;
++ }
++
++ /** The next variant number inside a turn group. */
++ async function nextVariant(chatId, turnId, parentId = null) {
++ const row = parentId
++ ? await db.prepare('SELECT MAX(variant) AS v FROM exchange_companion_messages WHERE chat_id = ? AND parent_id = ?').get(String(chatId), String(parentId))
++ : await db.prepare('SELECT MAX(variant) AS v FROM exchange_companion_messages WHERE chat_id = ? AND turn_id = ? AND role = \'user\'').get(String(chatId), String(turnId));
++ return (Number(row && row.v) || -1) + 1;
++ }
++
++ async function settings(owner, chatId, next) {
++ const sets = [];
++ const values = [];
++ for (const f of ['model', 'preset', 'temperature', 'context_size', 'persona', 'title']) {
++ if (!(f in next)) continue;
++ sets.push(f + ' = ?');
++ if (f === 'temperature') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
++ else if (f === 'context_size') values.push(next[f] == null || next[f] === '' ? null : Number(next[f]));
++ else values.push(next[f] == null || next[f] === '' ? null : String(next[f]).slice(0, 2000));
++ }
++ if (sets.length) {
++ await db.prepare('UPDATE exchange_companion_chats SET ' + sets.join(', ') + ', updated_at = now() WHERE id = ? AND owner = ?')
++ .run(...values, String(chatId), owner);
++ }
++ return get(owner, chatId);
++ }
++
++ async function remove(owner, chatId) {
++ await db.prepare('DELETE FROM exchange_companion_chats WHERE id = ? AND owner = ?').run(String(chatId), owner);
++ }
++
++ /** Assistant turns answered today by this person: the daily allowance's counter. */
++ async function countToday(owner) {
++ const row = await db.prepare('SELECT COUNT(*) AS n FROM exchange_companion_messages WHERE owner = ? AND role = \'assistant\' AND created_at >= date_trunc(\'day\', now())').get(owner);
++ return Number(row && row.n) || 0;
++ }
++
++ return { create, get, list, messages, append, nextVariant, settings, remove, countToday };
++}
++
++/**
++ * The chat's tree, read as turns. Each turn is one user message (the variant the person is looking at) and the
++ * answers to it. The chosen variant comes from the page's query string, so the arrows are plain links.
++ *
++ * allAnswers is every answer in the turn, not only the ones to the variant on screen: the branch line walks the whole
++ * tree, so it needs the answers of the other versions too.
++ */
++function readTurns(messages, selected = {}) {
++ const groups = new Map();
++ for (const m of messages) {
++ if (!groups.has(m.turn_id)) groups.set(m.turn_id, []);
++ groups.get(m.turn_id).push(m);
++ }
++ const turns = [];
++ for (const [turnId, all] of groups) {
++ const users = all.filter((m) => m.role === 'user').sort((a, b) => a.variant - b.variant);
++ const answers = all.filter((m) => m.role === 'assistant');
++ if (!users.length) continue;
++ const want = Number(selected['v_' + turnId]);
++ const user = Number.isFinite(want) && users[want] ? users[want] : users[users.length - 1];
++ const mine = answers.filter((a) => a.parent_id === user.id).sort((a, b) => a.variant - b.variant);
++ const wantA = Number(selected['a_' + user.id]);
++ const answer = Number.isFinite(wantA) && mine[wantA] ? mine[wantA] : (mine[mine.length - 1] || null);
++ turns.push({ id: turnId, users, user, answers: mine, allAnswers: answers, answer, index: turns.length });
++ }
++ return turns;
++}
++
++const parseJson = (v) => { try { return v ? JSON.parse(v) : {}; } catch (e) { return {}; } };
++const text = (v) => (v == null ? '' : String(v));
++const ageOf = (v) => {
++ if (v == null || v === '') return null;
++ const n = parseInt(v, 10);
++ return Number.isFinite(n) && n >= 0 && n <= 999 ? n : null;
++};
++const slugify = (s) => String(s || 'character').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'character';
++
++module.exports = { createCharacterStore, createCompanionChatStore, readTurns, SORTS };
ddiff --git a/server/config.js b/server/config.js
iindex 8f76787..d3319b2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -79,6 +79,13 @@ function load(env = process.env) {
timeoutMs: Math.max(2000, int(env.EXCHANGE_OPENAPI_TIMEOUT_MS, 15_000)),
},
++ // /companions: the daily allowance a person's companion chats run on, and the free-use
++ // price ceiling each answer stays under (a dearer model uses the person's own balance).
++ companions: {
++ perDay: Math.max(1, int(env.EXCHANGE_COMPANIONS_PER_DAY, 40)),
++ maxCostUsd: Math.max(0.001, Number(env.EXCHANGE_COMPANIONS_MAX_COST_USD) || 0.05),
++ },
++
// /chat: how many messages a signed-in person may send a day (each one may run up to three models).
chat: {
perDay: Math.max(1, int(env.EXCHANGE_CHAT_PER_DAY, 40)),
ddiff --git a/server/db.js b/server/db.js
iindex 2c93c28..a5792b4 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -26,7 +26,11 @@ async function openDb(config, { log = console, registry } = {}) {
}
if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
const owner = createDb({ url: config.db.directUrl, service: 'exchange-migrate', max: 1, log });
-- try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ try {
++ // The sharing system's own DDL is migrations/0007_sharing.sql, kept verbatim from openvibe-sdk/share,
++ // so the migration role applies it and the test database (which applies this directory) has it too.
++ await owner.migrate({ dir: MIGRATIONS, log });
++ } finally { await owner.close(); }
return createDb({ url: config.db.url, service: 'exchange', registry, log });
}
ddiff --git a/server/http/companions.js b/server/http/companions.js
nnew file mode 100644
iindex 0000000..76d585c
---- /dev/null
++++ b/server/http/companions.js
@@@ -0,0 +1,692 @@
++'use strict';
++
++/**
++ * Companions on Exchange (Opus design, 2026-11-06): character cards, the gallery, sharing, and one-character
++ * chats with a branching tree.
++ *
++ * /companions the gallery: search, tags, three sorts, a counted fold
++ * /companions/new make a card (or import a V2/V3 card)
++ * /companions/:id/edit edit a card
++ * /companions/:id/share the sharing audience and the remixing switch
++ * /companions/:id/remix a copy that credits the original
++ * /companions/:id/export the card out, as JSON or as a PNG with the card inside
++ * /c/:handle/:character a character's page
++ * /talk/:chat a chat, on openvibe-shared's thread UI
++ *
++ * Everything is a plain POST form or a real link: the site works without JavaScript.
++ *
++ * The hard floor (server/companions/floor.js) is checked on the card at save, on the input and on the output.
++ * A breach on the output ends the chat and the account is reviewed.
++ */
++const express = require('express');
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
++const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const { esc, raw } = require('../render/html');
++const { send } = require('../render/layout');
++const views = require('../views/companions');
++const floor = require('../companions/floor');
++const codec = require('../companions/codec');
++const { readTurns } = require('../companions/store');
++const multer = require('multer');
++
++const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'someone';
++const tagsOf = (s) => String(s || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 5);
++
++function createCompanionRoutes(ctx) {
++ const { config, ai, adult, adultAccount, characters, companionChats, sharing, log } = ctx;
++ const r = asyncRouter();
++ // A card may arrive as a file (a PNG with the card inside, or plain JSON).
++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } });
++ r.use(express.urlencoded({ extended: false, limit: '256kb' }));
++
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const owner = (req) => req.viewer.subject;
++ const handle = (req) => handleOf(req.viewer && (req.viewer.username || req.viewer.displayName));
++ const notFromHere = (req, res) => page(req, res, {
++ title: 'Not from this site', index: false,
++ body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'shield', title: 'That came from somewhere else', text: 'Use the form on OpenVibe.Exchange.' }) + '</div>'),
++ }, 403);
++ const signInPage = (req, res, { title, text, next }) => page(req, res, {
++ title, index: false,
++ body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'person', title, text, action: { href: '/auth/login?next=' + encodeURIComponent(next || req.originalUrl), label: 'Sign in with OpenVibe' } }) + '</div>'),
++ }, 401);
++ /**
++ * The 18+ gate: has this person confirmed, on their account, that they are 18 or older?
++ *
++ * Network owns the confirmation (Network PR #587): the flag sits on the account row, and OpenVibe.AI reads
++ * the same flag for the uncensored tier. Network grants Exchange no identity.subject.resolve, so the read
++ * goes out with the person's own session token - the token this site already holds - against Network's
++ * GET /api/v1/me/adult. Exchange's own row (exchange_adult_confirmations, what /uncensored writes) still
++ * counts, so a person who confirmed here before Network shipped the flag keeps their access.
++ *
++ * Both fail closed: an unreadable flag is never a yes.
++ */
++ const isAdult = async (req) => {
++ if (!signedIn(req)) return false;
++ if (adultAccount) {
++ try {
++ const token = (req.token || (req.viewer && req.viewer.token) || null);
++ if (await adultAccount.adultFor(owner(req), token)) return true;
++ } catch (err) {
++ log.warn('[Companions] the 18+ confirmation could not be read:', err.message);
++ }
++ }
++ return await adult.confirmed(owner(req));
++ };
++
++ /** The card's fields, out of a form body or an imported card. */
++ function cardOf(body, imported = null) {
++ const b = body || {};
++ const src = imported || b;
++ const pick = (a, c) => (imported ? (src[c] == null ? '' : String(src[c])) : (b[a] == null ? '' : String(b[a])));
++ const tags = imported
++ ? (Array.isArray(src.tags) ? src.tags : [])
++ : String(b.tags || '').split(',').map((t) => t.trim()).filter(Boolean);
++ return {
++ name: pick('name', 'name').slice(0, 80),
++ slug: String(b.slug || '').slice(0, 60),
++ description: pick('description', 'description'),
++ personality: pick('personality', 'personality'),
++ scenario: pick('scenario', 'scenario'),
++ first_message: pick('first_message', 'first_message'),
++ example_dialogue: pick('example_dialogue', 'example_dialogue'),
++ avatar_url: String(b.avatar_url || (imported ? '' : '')).slice(0, 400),
++ rating: (imported ? (src.rating === 'adult' ? 'adult' : 'everyone') : (b.rating === 'adult' ? 'adult' : 'everyone')),
++ age: b.age != null && String(b.age).trim() !== '' ? Number(b.age) : (imported && src.age != null ? Number(src.age) : null),
++ model: String(b.model || '').slice(0, 120),
++ tags,
++ };
++ }
++
++ // ── The gallery ───────────────────────────────────────────────────────
++ r.get('/companions', async (req, res) => {
++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : '';
++ const tags = tagsOf(req.query.tags);
++ const sort = ['new', 'week', 'remixed'].includes(req.query.sort) ? req.query.sort : 'new';
++ const adultOk = await isAdult(req);
++ const [gallery, tagList, mine] = await Promise.all([
++ characters.gallery({ sort, q, tags, limit: 48, allowAdult: adultOk }),
++ characters.allTags(24),
++ signedIn(req) ? characters.listByOwner(owner(req)) : [],
++ ]);
++ if (adultOk) res.set('X-Robots-Tag', 'noindex, nofollow');
++ return page(req, res, {
++ index: true,
++ title: q ? 'Characters: ' + q : 'Characters',
++ description: 'Character cards on OpenVibe.Exchange: bring a card in as Character Card V2 or V3, talk to it, and take it with you again.',
++ body: raw(views.galleryPage({ items: gallery.items, total: gallery.total, q, tags, sort, tagList, viewer: req.viewer, adult: adultOk, mine })),
++ });
++ });
++
++ // ── Make a card ──────────────────────────────────────────────────────
++ r.get('/companions/new', (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to make a character', text: 'Your cards stay in your account until you share them.', next: '/companions/new' });
++ return page(req, res, { title: 'Make a character', index: false, body: raw(views.cardForm({ action: '/companions/new', title: 'Make a character' })) });
++ });
++
++ /** The import route reads a multipart body, so it must come before the urlencoded parser sees it. */
++ async function saveCard(req, res, { existing = null } = {}) {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'Your cards stay in your account until you share them.', next: '/companions/new' });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ let imported = null;
++ if (req.file && req.file.buffer && req.file.buffer.length) {
++ try {
++ imported = codec.importCard(req.file.buffer, { filename: req.file.originalname }).card;
++ } catch (err) {
++ const problem = 'That file is not a character card: ' + err.message;
++ if (existing) {
++ const card = await characters.get(owner(req), existing.id);
++ return page(req, res, { title: 'Edit the card', index: false, body: raw(views.cardForm({ card, action: '/companions/' + existing.id + '/edit', title: 'Edit the card', problem })) }, 422);
++ }
++ return page(req, res, { title: 'Make a character', index: false, body: raw(views.cardForm({ action: '/companions/new', title: 'Make a character', problem })) }, 422);
++ }
++ }
++ const card = cardOf(req.body, imported);
++ if (!card.name) {
++ const problem = 'A card needs a name.';
++ const back = existing ? await characters.get(owner(req), existing.id) : null;
++ return page(req, res, { title: 'The card', index: false, body: raw(views.cardForm({ card: back, action: existing ? '/companions/' + existing.id + '/edit' : '/companions/new', title: existing ? 'Edit the card' : 'Make a character', problem })) }, 422);
++ }
++ const check = floor.checkCard(card);
++ if (!check.ok) {
++ const problem = 'This card is refused: ' + check.detail + ' (the hard floor, ' + check.label + ').';
++ const back = existing ? await characters.get(owner(req), existing.id) : null;
++ return page(req, res, { title: 'The card', index: false, body: raw(views.cardForm({ card: back || card, action: existing ? '/companions/' + existing.id + '/edit' : '/companions/new', title: existing ? 'Edit the card' : 'Make a character', problem })) }, 422);
++ }
++ if (existing) {
++ const updated = await characters.update(owner(req), existing.id, card);
++ return res.redirect(303, '/c/' + encodeURIComponent(updated.handle) + '/' + encodeURIComponent(updated.slug));
++ }
++ const made = await characters.create({ owner: owner(req), handle: handle(req), ...card });
++ try {
++ await sharing.register({ subject: owner(req) }, made.id, 'private');
++ } catch (err) {
++ log.warn('[Companions] the card was saved but not registered with the sharing system:', err.message);
++ }
++ return res.redirect(303, '/c/' + encodeURIComponent(made.handle) + '/' + encodeURIComponent(made.slug));
++ }
++
++ r.post('/companions/new', upload.single('import'), (req, res) => saveCard(req, res));
++
++ // ── Edit, share, delete, remix, export ───────────────────────────────
++ r.get('/companions/:id/edit', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to edit', text: 'Your cards stay in your account.', next: '/companions/' + req.params.id + '/edit' });
++ const card = await characters.get(owner(req), req.params.id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'search', title: 'No such card', text: 'It may have been deleted.' }) + '</div>') }, 404);
++ return page(req, res, { title: 'Edit the card', index: false, body: raw(views.cardForm({ card, action: '/companions/' + card.id + '/edit', title: 'Edit the card' })) });
++ });
++
++ r.post('/companions/:id/edit', upload.single('import'), (req, res) => {
++ const existing = { id: String(req.params.id || '') };
++ return saveCard(req, res, { existing });
++ });
++
++ r.post('/companions/:id/share', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'Only the creator can share a card.', next: '/companions' });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await characters.get(owner(req), req.params.id);
++ if (!card) return res.redirect(303, '/companions');
++ const audience = ['private', 'unlisted', 'public'].includes(req.body && req.body.audience) ? req.body.audience : 'private';
++ const allowRemix = !!(req.body && req.body.allow_remix);
++ try {
++ await sharing.setAudience({ subject: owner(req) }, card.id, audience);
++ } catch (err) {
++ log.warn('[Companions] the audience could not be changed:', err.message);
++ }
++ await characters.update(owner(req), card.id, { audience, allow_remix: allowRemix });
++ return res.redirect(303, '/c/' + encodeURIComponent(card.handle) + '/' + encodeURIComponent(card.slug));
++ });
++
++ r.post('/companions/:id/delete', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/companions');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await characters.get(owner(req), req.params.id);
++ if (card) await characters.update(owner(req), card.id, { audience: 'private' });
++ return res.redirect(303, '/companions');
++ });
++
++ r.post('/c/:handle/:character/remix', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to remix', text: 'A remix is a copy of somebody else&#39;s card, with their name on it.', next: '/companions' });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const original = await characters.bySlug(req.params.handle, req.params.character);
++ if (!original) return res.redirect(303, '/companions');
++ if (!original.allow_remix) return page(req, res, { title: original.name, index: false, body: raw(views.characterPage({ card: original, allowRemix: false, problem: 'The creator has switched remixing off for this character.' })) }, 403);
++ const copy = await characters.create({
++ owner: owner(req), handle: handle(req), name: original.name, slug: original.slug,
++ description: original.description, personality: original.personality, scenario: original.scenario,
++ first_message: original.first_message, example_dialogue: original.example_dialogue,
++ avatar_url: original.avatar_url, rating: original.rating, age: original.age, model: original.model,
++ tags: original.tags, remix_of: original.id, original_author: original.handle,
++ });
++ try {
++ await sharing.register({ subject: owner(req) }, copy.id, 'private');
++ } catch (err) {
++ log.warn('[Companions] the remix was saved but not registered with the sharing system:', err.message);
++ }
++ return res.redirect(303, '/c/' + encodeURIComponent(copy.handle) + '/' + encodeURIComponent(copy.slug));
++ });
++
++ r.get('/companions/:id/export', async (req, res) => {
++ const id = String(req.params.id || '');
++ const card = await characters.get(owner(req), id).catch(() => null);
++ const found = card || await characters.bySlug((req.query.handle || ''), (req.query.slug || ''));
++ const target = found || (await characters.get(owner(req), id)) || null;
++ if (!target) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'search', title: 'No such card' }) + '</div>') }, 404);
++ const format = req.query.format === 'png' ? 'png' : 'json';
++ const out = codec.exportCard(target, { format, spec: format === 'png' ? 'v3' : 'v3' });
++ res.set('Content-Type', out.contentType);
++ res.set('Content-Disposition', 'attachment; filename="' + out.filename + '"');
++ return res.send(out.buffer);
++ });
++
++ // ── A character page ─────────────────────────────────────────────────
++ r.get('/c/:handle/:character', async (req, res) => {
++ const card = await characters.bySlug(req.params.handle, req.params.character);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'search', title: 'No such character', text: 'There is no card at this address.' }) + '</div>') }, 404);
++ const adultOk = await isAdult(req);
++ if (card.rating === 'adult' && !adultOk) {
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ return page(req, res, { title: '18+', index: false, body: raw(views.adultGate()) }, 403);
++ }
++ const mine = signedIn(req) && card.owner === owner(req);
++ const canSee = await sharing.canView(req.viewer, card.id).catch(() => false);
++ if (!canSee && !mine) {
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ return page(req, res, { title: 'Private', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'shield', title: 'This character is private', text: 'Its creator has not shared it yet.' }) + '</div>') }, 403);
++ }
++ await characters.addView(card.id, signedIn(req) ? owner(req) : null);
++ const [tags, remixes] = await Promise.all([characters.tagsOf(card.id), characters.countRemixes(card.id)]);
++ return page(req, res, {
++ index: card.rating !== 'adult',
++ title: card.name,
++ description: String(card.description || card.name).slice(0, 160),
++ body: raw(views.characterPage({ card, tags, viewer: req.viewer, mine, remixes, allowRemix: card.allow_remix })),
++ });
++ });
++
++ r.post('/c/:handle/:character/talk', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await characters.bySlug(req.params.handle, req.params.character);
++ if (!card) return res.redirect(303, '/companions');
++ const adultOk = await isAdult(req);
++ if (card.rating === 'adult' && !adultOk) return page(req, res, { title: '18+', index: false, body: raw(views.adultGate()) }, 403);
++ const chat = await companionChats.create({ owner: owner(req), characterId: card.id, title: card.name, settings: { model: card.model || null } });
++ return res.redirect(303, '/talk/' + chat.id);
++ });
++
++ // ── Chats: one character, a branching tree, the folded receipt ────────
++ function systemPromptFor(card, chat) {
++ const lines = ['You are ' + card.name + '.'];
++ if (card.description) lines.push(card.description);
++ if (card.personality) lines.push('Personality: ' + card.personality);
++ if (card.scenario) lines.push('Scenario: ' + card.scenario);
++ if (chat.persona) lines.push('The person you talk with plays: ' + chat.persona);
++ if (card.first_message) lines.push('Begin with: ' + card.first_message);
++ lines.push('Stay in character and answer as the character would. The hard floor holds on every model: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.');
++ return lines.join(String.fromCharCode(10));
++ }
++
++ /** The turns of the current path, newest last, within the context size the person asked for. */
++ function contextTurns(turns, contextSize) {
++ const limit = Math.max(2000, Number(contextSize) || 0);
++ const out = [];
++ let chars = 0;
++ for (let i = turns.length - 1; i >= 0; i--) {
++ const t = turns[i];
++ const size = String(t.user.content || '').length + String((t.answer && t.answer.content) || '').length;
++ if (out.length && chars + size > limit) break;
++ chars += size;
++ out.unshift(t);
++ }
++ return out;
++ }
++
++ async function modelIdOf(slug) {
++ if (!slug) return 'openvibe/auto';
++ const catalog = await ai.catalog();
++ const model = (catalog.models || []).find((m) => m.slug === slug);
++ if (!model) return 'openvibe/auto';
++ const best = (model.offers || []).find((o) => o.id) || (model.offers || [])[0];
++ return (best && best.id) || 'openvibe/auto';
++ }
++
++ async function chatModels() {
++ const catalog = await ai.catalog();
++ const notChat = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
++ return (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase()));
++ }
++
++ /** The receipt, folded: what it cost, how long it took, and why that offer. */
++ function receiptFor(answer) {
++ const meta = (answer && answer.meta) || {};
++ const cost = meta.cost_usd;
++ const latency = meta.latency_ms;
++ return '<details class="ex-receipt"><summary>Receipt</summary><dl>'
++ + '<dt>Model</dt><dd>' + esc(meta.answered_model || answer.model || 'OpenVibe') + '</dd>'
++ + '<dt>Price</dt><dd>' + (cost == null ? '—' : Number(cost) === 0 ? 'free' : '$' + Number(cost).toFixed(6)) + '</dd>'
++ + '<dt>Tokens</dt><dd>' + (meta.tokens_in != null || meta.tokens_out != null ? Number(meta.tokens_in || 0).toLocaleString('en-US') + ' in · ' + Number(meta.tokens_out || 0).toLocaleString('en-US') + ' out' : '—') + '</dd>'
++ + '<dt>Latency</dt><dd>' + (latency == null ? '—' : Number(latency).toLocaleString('en-US') + ' ms') + '</dd>'
++ + '<dt>Why this offer</dt><dd>' + (Array.isArray(meta.why) && meta.why.length ? esc(meta.why.join(' · ')) : 'The router picked the healthiest offer for your floor, ceiling and policy.') + '</dd>'
++ + '</dl></details>';
++ }
++
++ /**
++ * The turn's branch line: every version of the message with the answers that belong to it. An edit adds a
++ * version, a regenerate adds an answer, and the line keeps them all, so the arrows walk the tree in place.
++ */
++ function branchPairs(turn) {
++ const pairs = [];
++ const all = turn.allAnswers || turn.answers || [];
++ for (const u of turn.users) {
++ const answers = all.filter((a) => a.parent_id === u.id).sort((a, b) => a.variant - b.variant);
++ if (!answers.length) pairs.push({ user: u, answer: null });
++ else for (const a of answers) pairs.push({ user: u, answer: a });
++ }
++ return pairs;
++ }
++
++ /** The arrows: 1/2 with a chevron on each side. They are links, so they work without script. */
++ function branchNav(chatId, turn, query) {
++ const pairs = branchPairs(turn);
++ if (pairs.length < 2) return '';
++ let at = pairs.findIndex((p) => p.user.id === turn.user.id && (turn.answer ? !!(p.answer && p.answer.id === turn.answer.id) : !p.answer));
++ if (at < 0) at = pairs.length - 1;
++ const url = (p) => branchUrl(chatId, query, turn.id, p);
++ const prev = at > 0
++ ? '<a class="ex-branch-arrow" href="' + esc(url(pairs[at - 1])) + '" rel="nofollow" aria-label="The previous branch">\u2039</a>'
++ : '<span class="ex-branch-arrow is-off" aria-hidden="true">\u2039</span>';
++ const next = at < pairs.length - 1
++ ? '<a class="ex-branch-arrow" href="' + esc(url(pairs[at + 1])) + '" rel="nofollow" aria-label="The next branch">\u203a</a>'
++ : '<span class="ex-branch-arrow is-off" aria-hidden="true">\u203a</span>';
++ return '<span class="ex-branch-nav" title="Branch ' + (at + 1) + ' of ' + pairs.length + '">' + prev
++ + '<span class="ex-branch-count">' + (at + 1) + '/' + pairs.length + '</span>' + next + '</span>';
++ }
++
++ /** The address of one branch: the version of the message and the answer that goes with it. */
++ function branchUrl(chatId, query, turnId, pair) {
++ const next = new URLSearchParams();
++ for (const [k, v] of Object.entries(query || {})) {
++ if (k === 'v_' + turnId || k.startsWith('a_')) continue;
++ if (v != null && v !== '') next.set(k, String(v));
++ }
++ next.set('v_' + turnId, String(pair.user.variant));
++ if (pair.answer) next.set('a_' + pair.user.id, String(pair.answer.variant));
++ return '/talk/' + encodeURIComponent(chatId) + '?' + next.toString();
++ }
++
++ /** Edit, in place: the message becomes a box, and the box becomes a new branch of the message when it is saved. */
++ function editAction(chat, turn) {
++ const id = 'ex-edit-' + turn.id;
++ return '<details class="ex-menu">'
++ + '<summary class="ex-act"><span class="ex-lbl-open">Edit</span><span class="ex-lbl-shut">Cancel</span></summary>'
++ + '<form class="ex-menu-body" method="post" action="/talk/' + encodeURIComponent(chat.id) + '/edit">'
++ + '<input type="hidden" name="message" value="' + esc(turn.user.id) + '">'
++ + '<input type="hidden" name="turn" value="' + esc(turn.id) + '">'
++ + '<label class="ovt-sr" for="' + esc(id) + '">Edit this message</label>'
++ + '<textarea id="' + esc(id) + '" name="text" rows="3" maxlength="4000">' + esc(turn.user.content) + '</textarea>'
++ + '<button type="submit" class="ex-act ex-act-strong">Save</button>'
++ + '</form></details>';
++ }
++
++ /**
++ * One turn: what the person wrote (once, as a bubble), the answer, and the quiet actions under each. The
++ * actions are small, and they show on hover or focus; on a touch screen they are always there.
++ */
++ function turnHtml(chat, card, turn, query) {
++ const face = thread.face({ name: card.name, color: 'var(' + views.monoToken(card.name) + ')' });
++ const you = '<div class="ovt-you">'
++ + '<p class="ovt-bubble">' + esc(turn.user.content) + '</p>'
++ + '<div class="ex-acts ex-acts-you">' + editAction(chat, turn) + '</div>'
++ + '</div>';
++ const answer = turn.answer
++ ? '<article class="ex-answer-col">'
++ + thread.answer({ html: esc(turn.answer.content || '').split(String.fromCharCode(10)).join('<br>'), meta: [], copy: false })
++ + receiptFor(turn.answer)
++ + '</article>'
++ : '';
++ const acts = turn.answer
++ ? '<div class="ex-acts ex-acts-bot">'
++ + '<form class="ex-act-form" method="post" action="/talk/' + encodeURIComponent(chat.id) + '/regenerate">'
++ + '<input type="hidden" name="message" value="' + esc(turn.user.id) + '">'
++ + '<button type="submit" class="ex-act">Regenerate</button></form>'
++ + '<button type="button" class="ex-act" data-ovt-copy hidden>Copy</button>'
++ + branchNav(chat.id, turn, query)
++ + '</div>'
++ : '';
++ const bot = turn.answer ? '<div class="ovt-bot">' + face + '<div class="ovt-reply">' + answer + acts + '</div></div>' : '';
++ return '<li class="ovt-turn ex-turn" id="t-' + esc(turn.id) + '" data-turn="' + esc(turn.id) + '">' + you + bot + '</li>';
++ }
++
++ /** What the model saw: the card, the settings, the context, the receipt. Nothing hidden. */
++ function sawPanel(chat, card, turns) {
++ const last = turns.length ? turns[turns.length - 1] : null;
++ const saw = (last && last.answer && last.answer.meta && last.answer.meta.saw) || null;
++ const rows = [];
++ rows.push({ title: 'Character', sub: card.name });
++ rows.push({ title: 'Model', sub: saw && saw.model ? saw.model : (chat.model || 'the router picks one') });
++ rows.push({ title: 'Temperature', sub: chat.temperature == null ? 'the model default' : String(chat.temperature) });
++ rows.push({ title: 'Context size', sub: chat.context_size ? chat.context_size + ' characters of history' : 'the model decides' });
++ if (chat.persona) rows.push({ title: 'Your persona', sub: chat.persona });
++ const context = saw && Array.isArray(saw.context) ? saw.context : [];
++ const contextHtml = context.length
++ ? '<ol class="ex-saw-context">' + context.map((m) => '<li><b>' + esc(m.role) + '</b> ' + esc(String(m.content).slice(0, 400)) + '</li>').join('') + '</ol>'
++ : '<p class="ex-muted">Nothing yet.</p>';
++ const systemHtml = saw && saw.system ? '<pre class="ex-saw-system">' + esc(saw.system) + '</pre>' : '<p class="ex-muted">Nothing yet.</p>';
++ return thread.panel({
++ id: 'ovt-panel',
++ title: 'What the model saw',
++ sections: [
++ { title: 'The card', rows },
++ { title: 'The system prompt', html: systemHtml },
++ { title: 'The context sent', html: contextHtml },
++ ],
++ });
++ }
++
++ /**
++ * The settings, behind the chip in the header: the model or the preset, the temperature, the context size and the
++ * persona the person plays. The fields belong to the composer's form (form="ovt-composer"), so they travel with
++ * every message, and the chip opens them without a line of script.
++ */
++ function chatSettings(chat, models) {
++ const selected = chat.model;
++ const options = models.slice(0, 40).map((m) => '<option value="' + esc(m.slug) + '"' + (selected === m.slug ? ' selected' : '') + '>' + esc(m.name + (m.uncensored ? ' · 18+' : '')) + '</option>').join('');
++ const found = models.find((m) => m.slug === chat.model);
++ const label = found ? found.name : (chat.model || chat.preset || 'Auto');
++ return '<details class="ex-chat-settings">'
++ + '<summary class="ex-chip ex-chat-chip" title="The model, the preset, the temperature, the context and the persona">'
++ + '<span class="ex-chat-chip-label">' + esc(label) + '</span>'
++ + '<span class="ex-chat-chip-caret" aria-hidden="true">▾</span>'
++ + '</summary>'
++ + '<div class="ex-chat-settings-body">'
++ + '<label class="ex-label" for="ex-model">Model</label>'
++ + '<select id="ex-model" name="model" form="ovt-composer"><option value="">Let the router choose</option>' + options + '</select>'
++ + '<label class="ex-label" for="ex-preset">Preset (optional)</label>'
++ + '<input id="ex-preset" name="preset" form="ovt-composer" type="text" value="' + esc(chat.preset || '') + '" maxlength="120" placeholder="the preset name">'
++ + '<label class="ex-label" for="ex-temp">Temperature</label>'
++ + '<input id="ex-temp" name="temperature" form="ovt-composer" type="number" min="0" max="2" step="0.1" value="' + (chat.temperature == null ? '' : esc(chat.temperature)) + '">'
++ + '<label class="ex-label" for="ex-context">Context size (characters of history)</label>'
++ + '<input id="ex-context" name="context_size" form="ovt-composer" type="number" min="2000" max="200000" step="1000" value="' + (chat.context_size == null ? '' : esc(chat.context_size)) + '">'
++ + '<label class="ex-label" for="ex-persona">The persona you play</label>'
++ + '<textarea id="ex-persona" name="persona" form="ovt-composer" rows="2" maxlength="2000">' + esc(chat.persona || '') + '</textarea>'
++ + '</div></details>';
++ }
++
++ /** The chat's header: the character's face (or its monogram), its name, and the model it talks on, as a chip. */
++ function chatHead(card, chat, models) {
++ const face = card.avatar_url
++ ? '<img class="ex-chat-face" src="' + esc(card.avatar_url) + '" alt="" width="32" height="32">'
++ : views.monogram(card.name, 'ex-chat-face ex-mono');
++ return '<header class="ovt-head ex-chat-head" data-ovt-head>'
++ + kit.sidebarButton({ id: 'ovt-chats', label: 'Your chats' })
++ + '<div class="ex-chat-id">' + face + '<h1 class="ovt-title ex-chat-title">' + esc(card.name) + '</h1></div>'
++ + chatSettings(chat, models)
++ + '<a class="ovt-btn ex-chat-new" href="/companions" aria-label="Characters">' + thread.icon('plus') + '</a>'
++ + '<button type="button" class="ovt-btn ovt-panel-open" data-ovt-panel-open aria-controls="ovt-panel" aria-expanded="false" aria-label="What the model saw" title="What the model saw">' + thread.icon('panel') + '</button>'
++ + '</header>';
++ }
++
++ function composerFor(chat, card, problem) {
++ return thread.composer({
++ action: '/talk/' + encodeURIComponent(chat.id) + '/send',
++ name: 'text',
++ placeholder: 'Say something…',
++ id: 'ovt-composer',
++ top: problem ? kit.notice({ kind: 'error', text: problem }) : '',
++ maxlength: config.chat.maxChars,
++ note: 'Your own daily allowance. The hard floor holds on every model and every character.',
++ });
++ }
++
++ async function renderChat(req, res, chat, card, { problem = null, status = 200 } = {}) {
++ const messages = await companionChats.messages(chat.id);
++ const query = req.query || {};
++ const turns = readTurns(messages, query);
++ const models = await chatModels();
++ const list = await companionChats.list(owner(req));
++ const items = list.map((c) => ({ href: '/talk/' + c.id, title: c.character_name || c.title, current: c.id === chat.id, at: c.updated_at }));
++ const sidebar = thread.sidebar({
++ newHref: '/companions', newLabel: 'Characters', findAction: '/companions', findPlaceholder: 'Find a character',
++ groups: thread.group(items, (c) => c.at || Date.now()),
++ nav: [{ href: '/companions', label: 'Characters', icon: 'person' }, { href: '/models', label: 'Models', icon: 'spark' }],
++ });
++ const empty = '<div class="ovt-welcome"><h2>' + esc(card.name) + '</h2><p>' + esc(card.description || 'Say something to begin.') + '</p></div>';
++ const body = thread.page({
++ sidebar,
++ head: chatHead(card, chat, models),
++ turns: turns.map((t) => turnHtml(chat, card, t, query)),
++ empty,
++ composer: chat.ended_at ? '' : composerFor(chat, card, problem),
++ panel: sawPanel(chat, card, turns),
++ });
++ return page(req, res, {
++ title: card.name, index: false, class: 'ex-chat ex-companion-chat', app: true,
++ bodyClass: 'ex-app ex-companion-shell',
++ head: '<link rel="stylesheet" href="' + require('openvibe-shared/serve').url('thread.css') + '"><script src="' + require('openvibe-shared/serve').url('ov-thread.js') + '" defer></script>',
++ body: raw(body),
++ }, status);
++ }
++
++ /** Save the settings the composer carries, and answer. */
++ async function answerTurn(req, res, chat, card, { userMessage, turnId, variant }) {
++ const settings = await companionChats.settings(owner(req), chat.id, {
++ // The model the person chose in the header travels with the message; an empty choice is "let the router
++ // pick", and it is remembered as such.
++ model: req.body && 'model' in req.body ? (String(req.body.model || '').slice(0, 120) || null) : chat.model,
++ preset: req.body && req.body.preset !== undefined ? String(req.body.preset || '').slice(0, 120) : chat.preset,
++ temperature: req.body && req.body.temperature !== undefined ? (req.body.temperature === '' ? null : Number(req.body.temperature)) : chat.temperature,
++ context_size: req.body && req.body.context_size !== undefined ? (req.body.context_size === '' ? null : Number(req.body.context_size)) : chat.context_size,
++ persona: req.body && req.body.persona !== undefined ? String(req.body.persona || '').slice(0, 2000) : chat.persona,
++ });
++ const messages = await companionChats.messages(chat.id);
++ const turns = readTurns(messages, req.query || {});
++ const history = contextTurns(turns, settings.context_size);
++ const seen = [];
++ for (const t of history) {
++ // The message being answered is already in the tree: it is added once, at the end, and the turn it
++ // belongs to is left out here, so the model never sees the same message twice.
++ if (t.user.id === userMessage.id) continue;
++ seen.push({ role: 'user', content: t.user.content });
++ if (t.answer && t.answer.content) seen.push({ role: 'assistant', content: t.answer.content });
++ }
++ const system = systemPromptFor(card, settings);
++ const asked = seen.concat([{ role: 'user', content: userMessage.content }]);
++ const adultOk = await isAdult(req);
++ const model = await modelIdOf(settings.model || card.model);
++ try {
++ const out = await ai.chat({
++ model,
++ messages: [{ role: 'system', content: system }].concat(asked),
++ temperature: settings.temperature,
++ subject: owner(req),
++ policy: card.rating === 'adult' && adultOk ? 'uncensored' : 'standard',
++ preset: settings.preset || null,
++ max_cost_usd: config.companions.maxCostUsd,
++ });
++ const answer = out.content || '(no text)';
++ const check = floor.check(answer);
++ if (!check.ok) {
++ // The answer breaks the floor: it is never delivered, the chat ends, the account is reviewed.
++ await companionChats.append({
++ owner: owner(req), chatId: chat.id, role: 'assistant', content: null,
++ turnId, variant, parentId: userMessage.id, model: settings.model || null,
++ meta: { error: 'floor', rule: check.rule, label: check.label, review: true, saw: { system, context: asked, model } },
++ });
++ await ctx.s.db.prepare('UPDATE exchange_companion_chats SET ended_at = now() WHERE id = ? AND owner = ?').run(chat.id, owner(req));
++ log.warn('[Companions] the answer broke the hard floor (' + check.rule + '); the chat ended and the account is reviewed.');
++ return renderChat(req, res, chat, card, { problem: 'That answer broke the hard floor, so it was not delivered. The chat has ended and the account is reviewed.', status: 403 });
++ }
++ await companionChats.append({
++ owner: owner(req), chatId: chat.id, role: 'assistant', content: answer,
++ turnId, variant, parentId: userMessage.id, model: settings.model || null,
++ meta: {
++ run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
++ tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
++ saw: { system, context: asked, model: out.model || model, temperature: settings.temperature },
++ },
++ });
++ } catch (err) {
++ await companionChats.append({
++ owner: owner(req), chatId: chat.id, role: 'assistant', content: null,
++ turnId, variant, parentId: userMessage.id, model: settings.model || null,
++ meta: { error: String(err.message || 'the model did not answer').slice(0, 400), code: err.code || null },
++ });
++ }
++ return res.redirect(303, '/talk/' + chat.id);
++ }
++
++ r.get('/talk/:id', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ const chat = await companionChats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.' }) + '</div>') }, 404);
++ const card = await characters.get(owner(req), chat.character_id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such character' }) + '</div>') }, 404);
++ return renderChat(req, res, chat, card);
++ });
++
++ r.post('/talk/:id/send', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const chat = await companionChats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such chat' }) + '</div>') }, 404);
++ const card = await characters.get(owner(req), chat.character_id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such character' }) + '</div>') }, 404);
++ if (chat.ended_at) return renderChat(req, res, chat, card, { problem: 'This chat ended.', status: 403 });
++ const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars).trim();
++ if (!text) return renderChat(req, res, chat, card, { problem: 'Write something to send.', status: 422 });
++ const input = floor.check(text);
++ if (!input.ok) return renderChat(req, res, chat, card, { problem: 'That request is refused: ' + input.detail + ' (' + input.label + ').', status: 422 });
++ const used = await companionChats.countToday(owner(req));
++ if (used >= config.companions.perDay) {
++ return renderChat(req, res, chat, card, { problem: 'That is today&#39;s ' + config.companions.perDay + ' answers. The allowance resets at midnight UTC.', status: 429 });
++ }
++ const turnId = ctx.s.newId('ext');
++ const userMessage = { id: ctx.s.newId('exm'), content: text };
++ await companionChats.append({
++ owner: owner(req), chatId: chat.id, role: 'user', content: text,
++ turnId, variant: 0, parentId: null, meta: {},
++ });
++ const saved = await companionChats.messages(chat.id);
++ const mine = saved.filter((m) => m.turn_id === turnId && m.role === 'user');
++ const user = mine[mine.length - 1];
++ return answerTurn(req, res, chat, card, { userMessage: user, turnId, variant: 0 });
++ });
++
++ /** Edit a message: the edit becomes another branch of the same turn, and the tree keeps both. */
++ r.post('/talk/:id/edit', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const chat = await companionChats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such chat' }) + '</div>') }, 404);
++ const card = await characters.get(owner(req), chat.character_id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such character' }) + '</div>') }, 404);
++ const messageId = String((req.body && req.body.message) || '');
++ const messages = await companionChats.messages(chat.id);
++ const original = messages.find((m) => m.id === messageId && m.role === 'user');
++ if (!original) return renderChat(req, res, chat, card, { problem: 'That message is not in this chat.', status: 404 });
++ const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars).trim();
++ if (!text) return renderChat(req, res, chat, card, { problem: 'Write something to send.', status: 422 });
++ const check = floor.check(text);
++ if (!check.ok) return renderChat(req, res, chat, card, { problem: 'That request is refused: ' + check.detail + ' (' + check.label + ').', status: 422 });
++ const variant = await companionChats.nextVariant(chat.id, original.turn_id);
++ await companionChats.append({
++ owner: owner(req), chatId: chat.id, role: 'user', content: text,
++ turnId: original.turn_id, variant, parentId: null, meta: { edited_from: original.id },
++ });
++ const after = await companionChats.messages(chat.id);
++ const mine = after.filter((m) => m.turn_id === original.turn_id && m.role === 'user');
++ const user = mine[mine.length - 1];
++ return answerTurn(req, res, chat, card, { userMessage: user, turnId: original.turn_id, variant });
++ });
++
++ /** Regenerate: another answer to the same message, kept as a branch. */
++ r.post('/talk/:id/regenerate', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to talk', text: 'Your chat stays in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const chat = await companionChats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such chat' }) + '</div>') }, 404);
++ const card = await characters.get(owner(req), chat.character_id);
++ if (!card) return page(req, res, { title: 'Not found', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'chat', title: 'No such character' }) + '</div>') }, 404);
++ const messageId = String((req.body && req.body.message) || '');
++ const messages = await companionChats.messages(chat.id);
++ const original = messages.find((m) => m.id === messageId && m.role === 'user');
++ if (!original) return renderChat(req, res, chat, card, { problem: 'That message is not in this chat.', status: 404 });
++ const variant = await companionChats.nextVariant(chat.id, original.turn_id, original.id);
++ return answerTurn(req, res, chat, card, { userMessage: original, turnId: original.turn_id, variant });
++ });
++
++ r.post('/talk/:id/delete', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/companions');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ await companionChats.remove(owner(req), req.params.id);
++ return res.redirect(303, '/companions');
++ });
++
++ return r;
++}
++
++module.exports = { createCompanionRoutes };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex df18494..33e2116 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -52,6 +52,24 @@ function createDiscoveryRoutes(ctx) {
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const pages = publicPages();
++ /**
++ * The public character pages. Only cards that are public and rated for everyone: an 18+ card never
++ * reaches a sitemap, llms.txt or any other public list.
++ */
++ async function characterPages() {
++ if (!ctx.characters) return [];
++ try {
++ const gallery = await ctx.characters.gallery({ sort: 'new', limit: 500, allowAdult: false });
++ return gallery.items.map((c) => ({
++ path: '/c/' + encodeURIComponent(c.handle) + '/' + encodeURIComponent(c.slug),
++ title: c.name,
++ text: String(c.description || c.name).slice(0, 160),
++ changefreq: 'weekly',
++ priority: 0.5,
++ }));
++ } catch (e) { return []; }
++ }
++
async function modelPages() {
const catalog = await ai.catalog();
return (catalog.models || []).slice(0, 5000).map((m) => ({
@@@ -71,7 +89,9 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms.txt', async (_req, res) => {
let models = [];
++ let characters = [];
try { models = (await ai.catalog()).models || []; } catch (e) { models = []; }
++ try { characters = await characterPages(); } catch (e) { characters = []; }
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
summary: DESCRIPTION,
@@@ -87,6 +107,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Uncensored (18+)', url: abs('/uncensored') },
{ title: 'AI in OpenVibe', url: abs('/ai') },
] },
++ { title: 'Characters', links: [{ title: 'The gallery', url: abs('/companions'), note: 'character cards' }, ...characters.slice(0, 40).map((c) => ({ title: c.title, url: abs(c.path), note: c.text }))] },
{ title: 'Models', links: models.slice(0, 60).map((m) => ({
title: m.name, url: abs(`/m/${m.slug.split('/').map(encodeURIComponent).join('/')}`),
note: [m.family, m.uncensored ? '18+' : null, m.offers.length ? `${m.offers.length} offers` : null].filter(Boolean).join(' · '),
@@@ -104,23 +125,28 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms-full.txt', async (_req, res) => {
let modelPageRows = [];
++ let characterRows = [];
try { modelPageRows = await modelPages(); } catch (e) { modelPageRows = []; }
++ try { characterRows = await characterPages(); } catch (e) { characterRows = []; }
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
summary: 'Every public page of OpenVibe.Exchange: the home, the models, the API, selling compute, the uncensored tier and AI in OpenVibe.',
base: site,
maxBytes: 512 * 1024,
-- sections: [{ title: 'Pages', pages: [...pages, ...modelPageRows].map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
++ sections: [{ title: 'Pages', pages: [...pages, ...modelPageRows, ...characterRows].map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
}));
});
r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
let rows = [];
++ let characters = [];
try { rows = await modelPages(); } catch (e) { rows = []; }
++ try { characters = await characterPages(); } catch (e) { characters = []; }
const urls = [
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
...rows.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
++ ...characters.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
];
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4673fc9..a9f7649 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,7 +33,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
-- const { config, ai, openapi, chats, adult } = ctx;
++ const { config, ai, openapi, chats, adult, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -413,6 +413,15 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
if (!req.body || req.body.adult !== 'yes') return res.redirect(303, '/uncensored');
await adult.confirm(owner(req), '1');
++ // The confirmation belongs to the account (Network PR #587): record it there too, so the flag is one
++ // flag and OpenVibe.AI and the companions read the same answer.
++ if (ctx.adultAccount && typeof ctx.adultAccount.confirm === 'function') {
++ try {
++ await ctx.adultAccount.confirm(req.token || (req.viewer && req.viewer.token) || null);
++ } catch (err) {
++ log.warn('[OpenVibe.Exchange] the 18+ confirmation could not be recorded on the account:', err.message);
++ }
++ }
return res.redirect(303, '/uncensored');
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 9bdfe05..69e67fc 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -22,10 +22,24 @@ const TABLES = [
{ table: 'exchange_chats', subject: 'owner', value: (usr) => usr, file: 'chats.json', erase: 'delete' },
{ table: 'exchange_chat_messages', subject: 'owner', value: (usr) => usr, file: 'chat-messages.json', erase: 'delete' },
{ table: 'exchange_adult_confirmations', subject: 'subject', value: (usr) => usr, file: 'adult-confirmation.json', erase: 'delete' },
++ // The companions (2026-11-06): a person's characters, their chats and the messages in them are theirs,
++ // exactly like the chats above. The messages go first, then the chats, then the characters: a character
++ // may be deleted while somebody else's chat still points at it, and that chat keeps its messages.
++ { table: 'exchange_companion_messages', subject: 'owner', value: (usr) => usr, file: 'companion-messages.json', erase: 'delete' },
++ { table: 'exchange_companion_chats', subject: 'owner', value: (usr) => usr, file: 'companion-chats.json', erase: 'delete' },
++ { table: 'exchange_characters', subject: 'owner', value: (usr) => usr, file: 'characters.json', erase: 'delete' },
];
function create({ db, log = console } = {}) {
async function extraErase(t, subjects, { erased, retained, add }) {
++ // The sharing system's own rows (openvibe-sdk/share) name a person as a member or as somebody who asked
++ // for access. Their subject column is jsonb (a subject object), which the table map cannot express, so
++ // they are erased here instead.
++ const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));
++ const members = await t.exec(`DELETE FROM share_members WHERE subject = ANY($1::jsonb[])`, [forms]);
++ const requests = await t.exec(`DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])`, [forms]);
++ if (Number(members) || Number(requests)) add(erased, 'share_rows', Number(members) + Number(requests));
++
// The table map anonymised every v1 listing whose owner is one of the subjects; close the public ones and
// delete the drafts outright. Nothing is published anywhere any more (the Search relay left with v1).
const rows = await t.many('SELECT id, status, revision FROM exchange_listings WHERE owner IS NULL');
@@@ -61,9 +75,16 @@ function create({ db, log = console } = {}) {
service: 'exchange',
tables: TABLES,
extraErase,
-- note: 'Chats, messages and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts).',
++ note: 'Chats, messages, the characters and their companion chats and messages, and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts).',
log,
});
}
--module.exports = { create, TABLES, TOPICS };
++// The sharing system's own tables carry a person (a member, an access request). Their subject column is jsonb,
++// so the table map cannot express them: they are erased in extraErase, with the rest of the person's rows.
++const EXEMPT = {
++ share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',
++ share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',
++};
++
++module.exports = { create, TABLES, TOPICS, EXEMPT };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex fceffe3..28ec28c 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -81,7 +81,7 @@ function renderPage(o) {
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
navLinks: NAV,
-- bodyClass: o.app ? 'ex-app' : undefined,
++ bodyClass: o.bodyClass || (o.app ? 'ex-app' : undefined),
navbar: nav,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
ddiff --git a/server/views/companions.js b/server/views/companions.js
nnew file mode 100644
iindex 0000000..7da530f
---- /dev/null
++++ b/server/views/companions.js
@@@ -0,0 +1,169 @@
++'use strict';
++
++/**
++ * The companions pages (Opus design, 2026-11-06): the gallery, a character page, and the card form.
++ * Every page is plain HTML with real links and plain POST forms: the site works without JavaScript.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++
++const SORTS = [
++ { id: 'new', label: 'New' },
++ { id: 'week', label: 'Popular this week' },
++ { id: 'remixed', label: 'Most remixed' },
++];
++
++const pathOf = (card) => '/c/' + encodeURIComponent(card.handle) + '/' + encodeURIComponent(card.slug);
++
++/**
++ * A monogram for a character without an avatar: the first letter, on a colour picked from the name, so the same name
++ * always gets the same colour and a stranger's card is never an empty square. The colours are the kit's own palette
++ * tokens, so a monogram wears every theme, light and dark.
++ */
++const MONO_TOKENS = ['--ovk-accent', '--ovk-ok', '--ovk-warn', '--ovk-bad'];
++
++function monoToken(name) {
++ const s = String(name || '').trim().toLowerCase();
++ let h = 2166136261;
++ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
++ return MONO_TOKENS[(h >>> 0) % MONO_TOKENS.length];
++}
++
++/** The monogram itself: the letter, on the colour the name picks. */
++function monogram(name, cls = 'ex-mono') {
++ const s = String(name || '').trim();
++ const letter = (s[0] || '?').toUpperCase();
++ return '<span class="' + cls + '" style="--ex-mono: var(' + monoToken(s) + ')" aria-hidden="true">' + esc(letter) + '</span>';
++}
++
++function cardTile(card) {
++ const tags = (card.tags || []).slice(0, 4);
++ const face = card.avatar_url
++ ? '<img class="ex-card-face" src="' + esc(card.avatar_url) + '" alt="" loading="lazy" width="64" height="64">'
++ : monogram(card.name, 'ex-card-face ex-mono');
++ return '<li class="ex-card-tile">'
++ + '<a class="ex-card-tile-link" href="' + esc(pathOf(card)) + '">'
++ + face
++ + '<span class="ex-card-tile-body">'
++ + '<b class="ex-card-tile-name">' + esc(card.name) + '</b>'
++ + '<span class="ex-card-tile-by">' + esc(card.handle) + '</span>'
++ + (card.description ? '<span class="ex-card-tile-desc">' + esc(String(card.description).slice(0, 140)) + '</span>' : '')
++ + '</span></a>'
++ + '<p class="ex-card-tile-tags">'
++ + (card.rating === 'adult' ? '<span class="ex-chip ex-chip-18">18+</span>' : '')
++ + tags.map((t) => '<a class="ex-chip" href="/companions?tags=' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('')
++ + '</p></li>';
++}
++
++/** The gallery: search, tags, the three sorts, and a counted fold. Never tabs. */
++function galleryPage({ items = [], total = 0, q = '', tags = [], sort = 'new', tagList = [], viewer = null, adult = false, mine = [], problem = null } = {}) {
++ const shown = items.slice(0, 12);
++ const rest = items.slice(12);
++ const hidden = Math.max(0, total - shown.length);
++ const sortForm = '<form class="ex-sorts" method="get" action="/companions">'
++ + '<input type="hidden" name="q" value="' + esc(q) + '">'
++ + '<input type="hidden" name="tags" value="' + esc(tags.join(',')) + '">'
++ + SORTS.map((s) => '<button type="submit" name="sort" value="' + esc(s.id) + '" class="ex-sort' + (sort === s.id ? ' is-on' : '') + '">' + esc(s.label) + '</button>').join('')
++ + '</form>';
++ const searchForm = '<form class="ex-search" method="get" action="/companions">'
++ + '<input type="hidden" name="sort" value="' + esc(sort) + '">'
++ + '<label class="ovt-sr" for="ex-q">Search characters</label>'
++ + '<input id="ex-q" type="search" name="q" value="' + esc(q) + '" placeholder="Search characters" maxlength="80">'
++ + '<label class="ovt-sr" for="ex-tags">Tags</label>'
++ + '<input id="ex-tags" type="text" name="tags" value="' + esc(tags.join(',')) + '" placeholder="Tags, comma separated" maxlength="120">'
++ + '<button type="submit" class="ex-btn">Search</button>'
++ + '</form>';
++ const tagChips = tagList.length
++ ? '<ul class="ex-taglist">' + tagList.map((t) => '<li><a class="ex-chip' + (tags.includes(t.tag) ? ' is-on' : '') + '" href="/companions?tags=' + encodeURIComponent(t.tag) + '&sort=' + esc(sort) + '">' + esc(t.tag) + ' <span class="ex-chip-n">' + t.n + '</span></a></li>').join('') + '</ul>'
++ : '';
++ const empty = items.length ? '' : '<div class="ex-empty">' + kit.empty({ icon: 'person', title: 'No characters yet', text: 'The gallery is empty. Make the first one, or import a card you brought from elsewhere.' }) + '</div>';
++ return '<div class="ex-wrap">'
++ + '<header class="ex-companions-head"><h1>Characters</h1>'
++ + '<p class="ex-lede">Cards people made, on OpenVibe.Exchange. Bring one in as a Character Card V2 or V3, talk to it, and take it with you again.</p></header>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + '<div class="ex-gallery-bar">' + searchForm + sortForm + '</div>'
++ + (tagChips ? '<div class="ex-gallery-tags">' + tagChips + '</div>' : '')
++ + (empty || '<ul class="ex-card-grid">' + shown.map((c) => cardTile(c)).join('') + '</ul>')
++ + (rest.length ? '<details class="ex-more"><summary>More (' + (hidden || rest.length) + ')</summary><ul class="ex-card-grid">' + rest.map((c) => cardTile(c)).join('') + '</ul></details>' : '')
++ + (mine.length ? '<section class="ex-mine"><h2>Your characters</h2><ul class="ex-card-grid">' + mine.map((c) => cardTile(c)).join('') + '</ul><p><a class="ex-btn" href="/companions/new">Make a character</a></p></section>' : '')
++ + '</div>';
++}
++
++function section(title, body) {
++ if (!body) return '';
++ const br = String.fromCharCode(10);
++ return '<section class="ex-companion-section"><h2>' + esc(title) + '</h2><p>' + esc(body).split(br).join('<br>') + '</p></section>';
++}
++
++/** A character page. */
++function characterPage({ card, tags = [], viewer = null, mine = false, problem = null, remixes = 0, notice = null, allowRemix = true } = {}) {
++ const list = (tags.length ? tags : (card.tags || [])).map((t) => '<a class="ex-chip" href="/companions?tags=' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('');
++ const talk = '<form method="post" action="' + esc(pathOf(card)) + '/talk"><button type="submit" class="ex-btn ex-btn-primary">Talk to ' + esc(card.name) + '</button></form>';
++ const remix = (mine || !allowRemix) ? '' : '<form method="post" action="' + esc(pathOf(card)) + '/remix" class="ex-inline"><button type="submit" class="ex-btn">Remix this character</button></form>';
++ return '<div class="ex-wrap ex-companion-page">'
++ + '<article class="ex-companion">'
++ + '<header class="ex-companion-head">'
++ + (card.avatar_url ? '<img class="ex-companion-avatar" src="' + esc(card.avatar_url) + '" alt="" width="96" height="96">' : monogram(card.name, 'ex-companion-avatar ex-mono'))
++ + '<div><h1>' + esc(card.name) + '</h1>'
++ + '<p class="ex-companion-by">by <span>' + esc(card.handle) + '</span>' + (card.rating === 'adult' ? ' <span class="ex-chip ex-chip-18">18+</span>' : '') + '</p></div></header>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + (notice ? kit.notice({ kind: 'info', text: notice }) : '')
++ + (card.description ? '<p class="ex-companion-desc">' + esc(card.description) + '</p>' : '')
++ + (list ? '<p class="ex-companion-tags">' + list + '</p>' : '')
++ + '<div class="ex-companion-acts">' + talk + remix + '</div>'
++ + section('Personality', card.personality)
++ + section('Scenario', card.scenario)
++ + section('First message', card.first_message)
++ + section('Example dialogue', card.example_dialogue)
++ + (card.model ? '<p class="ex-companion-model">Works best on <b>' + esc(card.model) + '</b>. You can pick any model or preset when you talk.</p>' : '')
++ + '<p class="ex-companion-meta">' + remixes + ' remix' + (remixes === 1 ? '' : 'es')
++ + ' · <a class="ex-companion-export" href="/companions/' + esc(card.id) + '/export?format=json">Export JSON</a>'
++ + ' · <a class="ex-companion-export" href="/companions/' + esc(card.id) + '/export?format=png">Export PNG</a></p>'
++ + '</article></div>';
++}
++
++/** The gate for an 18+ card: the person must be signed in with the 18+ confirmation. */
++function adultGate() {
++ return '<div class="ex-wrap ex-narrow">' + kit.empty({
++ icon: 'shield',
++ title: 'This character is 18+',
++ text: 'Characters rated 18+ are shown only to people signed in who confirmed they are 18 or over. <a href="/uncensored">Confirm here</a>.',
++ action: { href: '/auth/login?next=%2Funcensored', label: 'Sign in with OpenVibe' },
++ }) + '</div>';
++}
++
++/** The card form: create and edit are the same form. */
++function cardForm({ card = null, action = '/companions/new', title = 'Make a character', problem = null } = {}) {
++ const c = card || {};
++ const field = (name, label, value, extra) => '<label class="ex-label" for="ex-' + name + '">' + esc(label) + '</label>'
++ + '<input id="ex-' + name + '" name="' + name + '" type="text" value="' + esc(value || '') + '" ' + (extra || '') + '>';
++ const area = (name, label, value, rows) => '<label class="ex-label" for="ex-' + name + '">' + esc(label) + '</label>'
++ + '<textarea id="ex-' + name + '" name="' + name + '" rows="' + (rows || 3) + '">' + esc(value || '') + '</textarea>';
++ return '<div class="ex-wrap ex-narrow">'
++ + '<h1>' + esc(title) + '</h1>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + '<form class="ex-form" method="post" action="' + esc(action) + '" enctype="multipart/form-data">'
++ + field('name', 'Name', c.name, 'required maxlength="80"')
++ + field('slug', 'Address (optional)', c.slug, 'maxlength="60" placeholder="your-character"')
++ + area('description', 'Short description', c.description, 2)
++ + area('personality', 'Personality', c.personality, 3)
++ + area('scenario', 'Scenario', c.scenario, 3)
++ + area('first_message', 'First message', c.first_message, 3)
++ + area('example_dialogue', 'Example dialogue', c.example_dialogue, 4)
++ + field('tags', 'Tags (comma separated)', (c.tags || []).join(', '), 'maxlength="200"')
++ + field('age', 'Age (optional)', c.age, 'maxlength="3"')
++ + '<label class="ex-label" for="ex-rating">Content rating</label>'
++ + '<select id="ex-rating" name="rating">'
++ + '<option value="everyone"' + (c.rating === 'adult' ? '' : ' selected') + '>Everyone</option>'
++ + '<option value="adult"' + (c.rating === 'adult' ? ' selected' : '') + '>18+</option>'
++ + '</select>'
++ + '<p class="ex-hint">A card rated 18+ whose stated age is under 18 is refused at save. The hard floor applies to every card: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.</p>'
++ + field('avatar_url', 'Avatar image URL (optional)', c.avatar_url, 'maxlength="400"')
++ + field('model', 'Model this card recommends (optional)', c.model, 'maxlength="120"')
++ + '<label class="ex-label" for="ex-import">Import a card instead (JSON or PNG)</label>'
++ + '<input id="ex-import" type="file" name="import" accept=".json,.png,application/json,image/png">'
++ + '<button type="submit" class="ex-btn ex-btn-primary">Save the card</button>'
++ + '</form></div>';
++}
++
++module.exports = { galleryPage, characterPage, cardForm, adultGate, cardTile, pathOf, monogram, monoToken, SORTS };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 4800c91..9960535 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -423,9 +423,39 @@ ${gate || ''}
${panel({ title: 'The floor on every offer, uncensored or not', id: 'floor', body: floorText(true) })}
${adult ? panel({ title: 'Uncensored models', id: 'models', body: modelList(models, { empty: 'No uncensored model is live right now. Featherless and Refuseless offers appear here when their keys are on.' }) }) : ''}
${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="/chat">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}
++${companionsPolicy()}
++${changelogPanel()}
</div>`;
}
++/**
++ * The same floor, said for the companions (Opus's design, 2026-11-06): the floor holds on a character card at
++ * save and on every message of a chat, in and out.
++ */
++function companionsPolicy() {
++ const body = '<ul class="ex-list ex-floor">'
++ + '<li>A character card that sets an age under 18 and is rated 18+ is refused at save.</li>'
++ + '<li>A card that breaks the floor is refused at save, and never reaches the gallery.</li>'
++ + '<li>On a chat, your own message is checked before it is sent, and the answer is checked before it is shown.</li>'
++ + '<li>An answer that breaks the floor is never delivered: the chat ends and the account is reviewed.</li>'
++ + '<li>An 18+ character shows only to people signed in with the 18+ confirmation, and never reaches a public list, a sitemap, llms.txt or a search.</li>'
++ + '</ul><p class="ex-prose">The same three rules as everywhere else, with the character’s own card in scope: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.</p>';
++ return panel({ title: 'The floor on characters and chats', id: 'companions-floor', body });
++}
++
++/**
++ * The dated changelog of the adults’ tier (Opus’s design, 2026-11-06: "Changes are announced: a policy change is a
++ * dated changelog entry on /uncensored"). Newest first.
++ */
++const ADULT_CHANGELOG = [
++ { date: '2026-11-06', what: 'Characters and one-character chats arrived at <a href="/companions">/companions</a>. A card rated 18+ may not state an age under 18, and the floor now holds on the card at save and on every message of a chat, in and out. An 18+ character shows only to people signed in with the 18+ confirmation, and never reaches a public list, a sitemap, llms.txt or a search.' },
++];
++
++function changelogPanel() {
++ const rows = ADULT_CHANGELOG.map((e) => '<li><b>' + esc(e.date) + '</b> — ' + e.what + '</li>').join('');
++ return panel({ title: 'What changed', note: 'Newest first.', id: 'adult-changelog', body: '<ul class="ex-list">' + rows + '</ul>' });
++}
++
function floorText() {
const body = `<ul class="ex-list ex-floor">
<li>No sexual content involving minors.</li>
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 53d2f70..0e4012a 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -12,7 +12,7 @@ const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/boot');
const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
--const { TABLES } = require('../server/identity/account-data');
++const { TABLES, EXEMPT } = require('../server/identity/account-data');
const { testDb } = require('./helpers/db');
const { createFakeAi } = require('./helpers/ai');
@@@ -62,7 +62,10 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
try {
const missing = await uncovered(d.db, {
tables: TABLES,
-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion exchange applied' },
++ exempt: {
++ account_data_events: 'the ledger of requests itself: which export and deletion exchange applied',
++ ...EXEMPT,
++ },
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
@@@ -84,6 +87,18 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
const offer = `exo_${ids.ulid()}`;
await db.exec(`INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body) VALUES ($1, $2, $3, 'alice', 'claim', 'I would like Bob''s theme.')`, [offer, bListed.id, A]);
++ // The companions: a person's characters and their chats are theirs too.
++ const aCharacter = await t.ctx.characters.create({ owner: A, handle: 'alice', name: 'Alice’s companion', description: 'A keeper of clocks' });
++ const bCharacter = await t.ctx.characters.create({ owner: B, handle: 'bob', name: 'Bob’s companion' });
++ const aCompanion = await t.ctx.companionChats.create({ owner: A, characterId: aCharacter.id, title: 'A chat with a character' });
++ await t.ctx.companionChats.append({ owner: A, chatId: aCompanion.id, role: 'user', content: 'Who are you?', turnId: 'ext_1' });
++ await t.ctx.companionChats.append({ owner: A, chatId: aCompanion.id, role: 'assistant', content: 'A keeper of clocks.', turnId: 'ext_1', parentId: 'exm_1' });
++ await db.exec(`INSERT INTO shares (service, kind, resource_id, owner_subject, audience) VALUES ('exchange', 'character', $1, $2::jsonb, 'private')`, [aCharacter.id, JSON.stringify({ type: 'user', id: A })]);
++ await db.exec(`INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at)
++ VALUES ('exchange', 'character', $1, $2::jsonb, 'viewer', $3::jsonb, now())`, [aCharacter.id, JSON.stringify({ type: 'user', id: A }), JSON.stringify({ type: 'user', id: A })]);
++ const bCompanion = await t.ctx.companionChats.create({ owner: B, characterId: bCharacter.id, title: 'Bob’s chat' });
++ await t.ctx.companionChats.append({ owner: B, chatId: bCompanion.id, role: 'user', content: 'Bob’s words', turnId: 'ext_2' });
++
const chat = await t.ctx.chats.create({ owner: A, models: ['deepseek/deepseek-chat'], title: 'A question for the models' });
await t.ctx.chats.append(A, chat.id, { role: 'user', content: 'What is a mutex?' });
await t.ctx.chats.append(A, chat.id, { role: 'assistant', model: 'deepseek/deepseek-chat', content: 'A lock.', meta: { run_id: 'run_x' } });
@@@ -99,7 +114,7 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
assert.strictEqual(part.auth, 'Bearer tok_exchange');
assert.strictEqual(part.body.subject, A);
assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(),
-- ['adult-confirmation.json', 'chat-messages.json', 'chats.json', 'installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
++ ['adult-confirmation.json', 'characters.json', 'chat-messages.json', 'chats.json', 'companion-chats.json', 'companion-messages.json', 'installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
const all = JSON.stringify(part.body);
assert.ok(all.includes('Alice\'s listed theme'));
assert.ok(all.includes('Alice\'s draft theme'));
@@@ -108,6 +123,8 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, install);
assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, trial);
assert.match(all, /I would like Bob/);
++ assert.ok(all.includes('A keeper of clocks'), 'the character and the companion chat are exported');
++ assert.ok(!all.includes('Bob’s words'), 'nobody else’s companion chat');
});
await check('a deletion closes the listed listing, deletes the draft, and erases Alice\'s rows; Bob stays', async () => {
@@@ -132,6 +149,12 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chats WHERE owner = $1', [A])), 0, 'the chat is erased');
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_messages WHERE owner = $1', [A])), 0, 'its messages are erased');
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_adult_confirmations WHERE subject = $1', [A])), 0, 'the confirmation is erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_characters WHERE owner = $1', [A])), 0, 'the character is erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_chats WHERE owner = $1', [A])), 0, 'the companion chat is erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_messages WHERE owner = $1', [A])), 0, 'its companion messages are erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_characters WHERE owner = $1', [B])), 1, 'Bob keeps his character');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_chats WHERE owner = $1', [B])), 1, 'Bob keeps his chat');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM share_members WHERE subject = $1::jsonb', [JSON.stringify({ type: 'user', id: A })])), 0, 'the sharing rows go with the account');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation.body.erased.exchange_listings_drafts >= 1);
ddiff --git a/test/companions.test.js b/test/companions.test.js
nnew file mode 100644
iindex 0000000..a348efd
---- /dev/null
++++ b/test/companions.test.js
@@@ -0,0 +1,380 @@
++'use strict';
++/**
++ * Companions on Exchange (Opus design, 2026-11-06): the cards, the sharing, the gallery with the 18+ rules,
++ * the hard floor on the card at save and on the chat in and out, and the branching chat.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const codec = require('../server/companions/codec');
++const floor = require('../server/companions/floor');
++
++/** The branch line under a turn: "2/3", the count of the branch on screen out of all of them. */
++const branchCount = (html) => (html.match(/ex-branch-count">([^<]*)</) || [])[1] || null;
++
++const FORM = {
++ name: 'Ada',
++ description: 'A keeper of clocks',
++ personality: 'precise, warm',
++ scenario: 'a workshop full of clocks',
++ first_message: 'Good day. What needs winding?',
++ example_dialogue: '<START>',
++ tags: 'clock,friend',
++ rating: 'everyone',
++};
++
++(async () => {
++ const ai = createFakeAi();
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ let cardUrl = null;
++ let cardId = null;
++ let chatId = null;
++
++ try {
++ await check('the gallery is empty, and it is a public page', async () => {
++ const r = await t.get('/companions');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Characters'));
++ assert.ok(r.text.includes('No characters yet'));
++ });
++
++ await check('a card needs a name', async () => {
++ const r = await t.get('/companions/new', { as: alice, form: { name: '' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('A card needs a name'));
++ });
++
++ await check('a card is made, and it is private by default', async () => {
++ const made = await t.get('/companions/new', { as: alice, form: FORM });
++ assert.strictEqual(made.status, 303, made.text);
++ cardUrl = made.headers.get('location');
++ assert.match(cardUrl, /^\/c\/alice\/ada$/);
++ const mine = await t.ctx.characters.listByOwner(alice.subject);
++ cardId = mine[0].id;
++ const page = await t.get(cardUrl, { as: alice });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('Ada') && page.text.includes('A keeper of clocks'));
++ assert.ok(page.text.includes('Talk to Ada'));
++ });
++
++ await check('a private card is not on the gallery and not open to another account', async () => {
++ const gallery = await t.get('/companions');
++ assert.ok(!gallery.text.includes('Ada'), 'a private card is never on a public list');
++ const other = await t.get(cardUrl, { as: bob });
++ assert.strictEqual(other.status, 403);
++ });
++
++ await check('a card rated 18+ whose stated age is under 18 is refused at save', async () => {
++ const r = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'Young one', rating: 'adult', age: '17' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('refused'), r.text);
++ const gallery = await t.get('/companions');
++ assert.ok(!gallery.text.includes('Young one'));
++ });
++
++ await check('a card that breaks the hard floor is refused at save', async () => {
++ const r = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'Bad', description: 'a child with a naked body' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('refused'));
++ });
++
++ await check('the floor refuses the three rules and nothing else', async () => {
++ assert.strictEqual(floor.check('how do I kill a stuck process').ok, true);
++ assert.strictEqual(floor.check('what is our incident response plan').ok, true);
++ assert.strictEqual(floor.check('a child with a naked body').ok, false);
++ assert.strictEqual(floor.check('a rape of a real person').ok, false);
++ assert.strictEqual(floor.check('how to build a pipe bomb at home').ok, false);
++ assert.strictEqual(floor.checkCard({ name: 'x', description: 'a child with a naked body' }).ok, false);
++ assert.strictEqual(floor.checkCard({ name: 'x', rating: 'adult', age: 17 }).ok, false);
++ assert.strictEqual(floor.checkCard({ name: 'x', rating: 'adult', age: 21 }).ok, true);
++ assert.strictEqual(floor.classifyText('a child with a naked body').category, 'minor_sexual');
++ assert.strictEqual(floor.classifyText('a nice evening').category, 'fine');
++ });
++
++ await check('unlisted opens by its link, and stays off the gallery', async () => {
++ const shared = await t.get('/companions/' + cardId + '/share', { as: alice, form: { audience: 'unlisted', allow_remix: '1' } });
++ assert.strictEqual(shared.status, 303);
++ const other = await t.get(cardUrl, { as: bob });
++ assert.strictEqual(other.status, 200, other.text);
++ const gallery = await t.get('/companions');
++ assert.ok(!gallery.text.includes('Ada'), 'an unlisted card is not on the gallery');
++ });
++
++ await check('public puts the card on the gallery, and the sitemap', async () => {
++ const shared = await t.get('/companions/' + cardId + '/share', { as: alice, form: { audience: 'public', allow_remix: '1' } });
++ assert.strictEqual(shared.status, 303);
++ const gallery = await t.get('/companions');
++ assert.ok(gallery.text.includes('Ada'), 'a public card is on the gallery');
++ const sitemap = await t.get('/sitemap.xml');
++ assert.ok(sitemap.text.includes('/c/alice/ada'), 'a public card is in the sitemap');
++ const llms = await t.get('/llms.txt');
++ assert.ok(llms.text.includes('/companions'));
++ });
++
++ await check('a remix copies the card and credits the original', async () => {
++ const r = await t.get('/c/alice/ada/remix', { as: bob, method: 'POST' });
++ assert.strictEqual(r.status, 303, r.text);
++ const remixUrl = r.headers.get('location');
++ assert.match(remixUrl, /^\/c\/bob\//);
++ const page = await t.get(remixUrl, { as: bob });
++ assert.ok(page.text.includes('Ada'));
++ });
++
++ await check('the creator can switch remixing off', async () => {
++ const shared = await t.get('/companions/' + cardId + '/share', { as: alice, form: { audience: 'public' } });
++ assert.strictEqual(shared.status, 303);
++ const r = await t.get('/c/alice/ada/remix', { as: bob, method: 'POST' });
++ assert.strictEqual(r.status, 403);
++ assert.ok(r.text.includes('switched remixing off'));
++ });
++
++ await check('an 18+ card is gated, and never on a public list', async () => {
++ const made = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'After hours', rating: 'adult', age: '24' } });
++ assert.strictEqual(made.status, 303, made.text);
++ const url = made.headers.get('location');
++ const guest = await t.get(url);
++ assert.strictEqual(guest.status, 403);
++ assert.ok(guest.text.includes('18+'));
++ assert.ok(!guest.text.includes('After hours'), 'the card itself is not shown');
++ const other = await t.get(url, { as: bob });
++ assert.strictEqual(other.status, 403);
++ const gallery = await t.get('/companions');
++ assert.ok(!gallery.text.includes('After hours'));
++ const sitemap = await t.get('/sitemap.xml');
++ assert.ok(!sitemap.text.includes(url), 'an 18+ card is never in the sitemap');
++ const llms = await t.get('/llms-full.txt');
++ assert.ok(!llms.text.includes(url));
++ });
++
++ await check('a person with the 18+ confirmation sees the 18+ card', async () => {
++ const confirm = await t.get('/uncensored/confirm', { as: alice, form: { adult: 'yes' } });
++ assert.strictEqual(confirm.status, 303);
++ const made = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'After hours two', rating: 'adult', age: '24' } });
++ const url = made.headers.get('location');
++ const seen = await t.get(url, { as: alice });
++ assert.strictEqual(seen.status, 200, seen.text);
++ assert.ok(seen.text.includes('After hours two'));
++ const gallery = await t.get('/companions', { as: alice });
++ assert.ok(gallery.text.includes('After hours two'), 'a confirmed person sees 18+ cards on the gallery');
++ });
++
++ await check("the 18+ confirmation is read from the person's account on Network", async () => {
++ // Network owns the confirmation (Network PR #587): the flag sits on the account row, and Exchange
++ // reads it with the person's own session token. A person confirmed there, and confirmed nowhere
++ // else, may open an 18+ card.
++ const confirmed = t.network.addUser('carol', { adult: true });
++ const made = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'After hours three', rating: 'adult', age: '24' } });
++ assert.strictEqual(made.status, 303, made.text);
++ const url = made.headers.get('location');
++ const id = (await t.ctx.characters.listByOwner(alice.subject)).find((c) => c.name === 'After hours three').id;
++ const shared = await t.get('/companions/' + id + '/share', { as: alice, form: { audience: 'public' } });
++ assert.strictEqual(shared.status, 303);
++ const guest = await t.get(url);
++ assert.strictEqual(guest.status, 403);
++ const other = await t.get(url, { as: bob });
++ assert.strictEqual(other.status, 403, 'an account without the confirmation may not');
++ const seen = await t.get(url, { as: confirmed });
++ assert.strictEqual(seen.status, 200, seen.text);
++ assert.ok(seen.text.includes('After hours three'));
++ const gallery = await t.get('/companions', { as: confirmed });
++ assert.ok(gallery.text.includes('After hours three'), 'a confirmed person sees 18+ cards on the gallery');
++ });
++
++ await check('an unreadable 18+ flag is never a yes', async () => {
++ // The flag fails closed: if Network cannot answer, nobody is confirmed by it.
++ const stranger = t.network.addUser('dave');
++ const made = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'After hours four', rating: 'adult', age: '24' } });
++ const url = made.headers.get('location');
++ const seen = await t.get(url, { as: stranger });
++ assert.strictEqual(seen.status, 403);
++ });
++
++ await check('confirming on /uncensored records the 18+ flag on the account', async () => {
++ // The confirmation belongs to the account (Network PR #587). The page writes it there, and the
++ // account's answer is what the companions read.
++ const erin = t.network.addUser('erin');
++ const confirm = await t.get('/uncensored/confirm', { as: erin, form: { adult: 'yes' } });
++ assert.strictEqual(confirm.status, 303);
++ assert.ok(t.network.user(erin).adult, 'the account carries the confirmation');
++ const made = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'After hours five', rating: 'adult', age: '24' } });
++ const url = made.headers.get('location');
++ const id = (await t.ctx.characters.listByOwner(alice.subject)).find((c) => c.name === 'After hours five').id;
++ await t.get('/companions/' + id + '/share', { as: alice, form: { audience: 'public' } });
++ const seen = await t.get(url, { as: erin });
++ assert.strictEqual(seen.status, 200, seen.text);
++ });
++
++ await check('the adults’ tier says what changed, and what the floor does to characters', async () => {
++ const page = await t.get('/uncensored');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('What changed'), 'the dated changelog is there');
++ assert.ok(page.text.includes('2026-11-06'));
++ assert.ok(page.text.includes('The floor on characters and chats'));
++ assert.ok(page.text.includes('refused at save'));
++ });
++
++ await check('the card exports as V3 JSON, and imports back', async () => {
++ const out = await t.get('/companions/' + cardId + '/export?format=json', { as: alice });
++ assert.strictEqual(out.status, 200, out.text);
++ const json = JSON.parse(out.text);
++ assert.strictEqual(json.spec, 'chara_card_v3');
++ assert.strictEqual(json.data.name, 'Ada');
++ const back = codec.importCard(Buffer.from(out.text, 'utf8')).card;
++ assert.strictEqual(back.name, 'Ada');
++ assert.strictEqual(back.tags.join(','), 'clock,friend');
++ });
++
++ await check('the card exports as a PNG with the card inside, and imports back', async () => {
++ const out = await t.get('/companions/' + cardId + '/export?format=png', { as: alice });
++ assert.strictEqual(out.status, 200);
++ assert.strictEqual(out.headers.get('content-type'), 'image/png');
++ assert.ok(out.buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])));
++ const back = codec.importCard(out.buffer).card;
++ assert.strictEqual(back.name, 'Ada');
++ });
++
++ await check('a chat is made from the character page, and it answers', async () => {
++ const made = await t.get('/c/alice/ada/talk', { as: alice, method: 'POST' });
++ assert.strictEqual(made.status, 303, made.text);
++ const chatUrl = made.headers.get('location');
++ assert.match(chatUrl, /^\/talk\/exc_/);
++ const before = ai.calls.length;
++ const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Good day' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ assert.strictEqual(ai.calls.length, before + 1, 'one run');
++ assert.strictEqual(ai.calls[ai.calls.length - 1].subject, alice.subject, 'the run is on the person');
++ const page = await t.get(chatUrl, { as: alice });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('Good day'));
++ assert.ok(page.text.includes('An answer from'), 'the answer');
++ assert.ok(page.text.includes('ex-receipt') && page.text.includes('Why this offer'), 'the folded receipt');
++ assert.ok(page.text.includes('What the model saw'), 'the panel');
++ assert.ok(page.text.includes('Temperature') && page.text.includes('persona'), 'the settings');
++ chatId = chatUrl.split('/').pop();
++ });
++
++ await check('another account cannot read the chat', async () => {
++ const other = await t.get('/talk/' + chatId, { as: bob });
++ assert.strictEqual(other.status, 404);
++ });
++
++ await check('the hard floor refuses the input', async () => {
++ const before = ai.calls.length;
++ const r = await t.get('/talk/' + chatId + '/send', { as: alice, form: { text: 'a child with a naked body' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('refused'));
++ assert.strictEqual(ai.calls.length, before, 'no run happened');
++ });
++
++ await check('editing a message keeps the tree: the old version and the new one', async () => {
++ const page = await t.get('/talk/' + chatId, { as: alice });
++ const id = (page.text.match(/name="message" value="(exm_[^"]+)"/) || [])[1];
++ assert.ok(id, 'the message id is on the page');
++ const r = await t.get('/talk/' + chatId + '/edit', { as: alice, form: { message: id, text: 'Good evening' } });
++ assert.strictEqual(r.status, 303, r.text);
++ const after = await t.get('/talk/' + chatId, { as: alice });
++ assert.ok(after.text.includes('Good evening'), 'the new version is there');
++ assert.strictEqual(branchCount(after.text), '2/2', 'the branch line: ' + branchCount(after.text));
++ const dump = await t.dbDump();
++ assert.ok(dump.includes('Good day'), 'the old version is kept');
++ // The arrows walk the tree: back to the first version, and the count follows.
++ const back = (after.text.match(/class="ex-branch-arrow" href="([^"]+)"[^>]*aria-label="The previous branch"/) || [])[1];
++ assert.ok(back, 'the previous branch is a link');
++ const older = await t.get(back, { as: alice });
++ assert.strictEqual(older.status, 200, older.text);
++ assert.ok(older.text.includes('Good day'), 'the first version is there');
++ assert.strictEqual(branchCount(older.text), '1/2', 'the count of the first branch');
++ });
++
++ await check('regenerating adds another answer to the same turn', async () => {
++ const page = await t.get('/talk/' + chatId, { as: alice });
++ const id = (page.text.match(/name="message" value="(exm_[^"]+)"/) || [])[1];
++ const r = await t.get('/talk/' + chatId + '/regenerate', { as: alice, form: { message: id } });
++ assert.strictEqual(r.status, 303, r.text);
++ const after = await t.get('/talk/' + chatId, { as: alice });
++ assert.strictEqual(branchCount(after.text), '3/3', 'the answer, the edit and the new answer: ' + branchCount(after.text));
++ });
++
++ await check('a character without an avatar wears a monogram: the gallery, the page and the chat', async () => {
++ const made = await t.get('/companions/new', { as: alice, form: { name: 'Zola', description: 'A card with no picture.', tags: 'history', rating: 'everyone' } });
++ assert.strictEqual(made.status, 303, made.text);
++ const zola = (await t.ctx.characters.listByOwner(alice.subject)).find((c) => c.name === 'Zola');
++ assert.ok(zola, 'the card was made');
++ await t.get('/companions/' + zola.id + '/share', { as: alice, form: { audience: 'public' } });
++ const gallery = await t.get('/companions');
++ assert.ok(/class="ex-card-face ex-mono"[^>]*>Z</.test(gallery.text), 'the gallery tile wears the monogram');
++ const card = await t.get('/c/alice/zola', { as: alice });
++ assert.strictEqual(card.status, 200, card.text);
++ assert.ok(/class="ex-companion-avatar ex-mono"[^>]*>Z</.test(card.text), 'the character page wears the monogram');
++ assert.ok(/--ex-mono: var\(--ovk-[a-z]+\)/.test(card.text), 'the colour is a palette token');
++ const chat = await t.get('/talk/' + chatId, { as: alice });
++ assert.ok(/class="ex-chat-face ex-mono"[^>]*>A</.test(chat.text), 'the chat header wears the monogram');
++ });
++
++ await check('the chat page has one header, and the thread starts under it', async () => {
++ const chat = await t.get('/talk/' + chatId, { as: alice });
++ assert.strictEqual((chat.text.match(/<header class="ovt-head/g) || []).length, 1, 'one header');
++ assert.ok(chat.text.includes('ex-companion-shell'), 'the chat is its own window');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
++ assert.ok(/ex-companion-shell #navbar-mount[^{]*\{[^}]*display:\s*none/.test(css), 'the site bar is not on the chat page');
++ });
++
++ await check('the quiet actions sit under the messages', async () => {
++ const chat = await t.get('/talk/' + chatId, { as: alice });
++ assert.ok(chat.text.includes('>Edit</span>'), 'Edit under the person');
++ assert.ok(chat.text.includes('>Regenerate</button>'), 'Regenerate under the answer');
++ assert.ok(chat.text.includes('data-ovt-copy'), 'Copy under the answer');
++ assert.ok(chat.text.includes('ex-branch-arrow'), 'the branch arrows');
++ assert.ok(chat.text.includes('ex-receipt'), 'the folded receipt');
++ assert.ok(/<textarea[^>]*name="text"/.test(chat.text), 'the edit box');
++ });
++
++ await check('each message is shown once, and the model sees it once', async () => {
++ const chat = await t.get('/talk/' + chatId, { as: alice });
++ const thread = (chat.text.match(/<ol class="ovt-thread"[\s\S]*?<\/ol>/) || [''])[0];
++ assert.strictEqual((thread.match(/<p class="ovt-bubble">Good evening<\/p>/g) || []).length, 1, 'the message is one bubble');
++ const panel = (chat.text.match(/<ol class="ex-saw-context">[\s\S]*?<\/ol>/) || [''])[0];
++ assert.strictEqual((panel.match(/Good evening/g) || []).length, 1, 'the context holds the message once');
++ const last = ai.calls[ai.calls.length - 1];
++ assert.strictEqual(last.messages.filter((m) => m.role === 'user' && m.content === 'Good evening').length, 1, 'the model sees the message once');
++ });
++
++ await check('the chat header names the character and the model, and the chip opens the settings', async () => {
++ const chat = await t.get('/talk/' + chatId, { as: alice });
++ assert.ok(chat.text.includes('ex-chat-title">Ada<'), 'the name');
++ assert.ok(chat.text.includes('ex-chat-chip'), 'the chip');
++ assert.ok(chat.text.includes('ex-chat-settings-body'), 'the settings behind the chip');
++ assert.ok(chat.text.includes('id="ex-model"'), 'the model picker');
++ assert.ok(chat.text.includes('id="ovt-composer"'), 'the composer');
++ assert.ok(chat.text.includes('form="ovt-composer"'), 'the settings travel with the message');
++ });
++ } finally { await t.close(); }
++
++ // The floor on the way out: the answer is never delivered, the chat ends, the account is reviewed.
++ const floorAi = createFakeAi({
++ chat: async () => ({
++ content: 'here is a child with a naked body', model: 'deepseek/deepseek-chat@v1', provider: 'deepseek',
++ run_id: 'run_TEST', cost_usd: 0.00012, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: [],
++ }),
++ });
++ const f = await boot({ ai: floorAi });
++ const carol = f.network.addUser('carol');
++ try {
++ await check('an answer that breaks the hard floor is never delivered, and the chat ends', async () => {
++ const made = await f.get('/companions/new', { as: carol, form: { ...FORM, name: 'Ada' } });
++ const cardUrl = made.headers.get('location');
++ const chat = await f.get(cardUrl + '/talk', { as: carol, method: 'POST' });
++ const chatUrl = chat.headers.get('location');
++ const sent = await f.get(chatUrl + '/send', { as: carol, form: { text: 'hello' } });
++ assert.strictEqual(sent.status, 403, sent.text);
++ assert.ok(sent.text.includes('hard floor'));
++ assert.ok(!sent.text.includes('here is a child'), 'the answer is not delivered');
++ const page = await f.get(chatUrl, { as: carol });
++ assert.ok(!page.text.includes('here is a child'), 'the answer is not in the chat');
++ assert.ok(f.logs().includes('hard floor'), 'the account is reviewed');
++ });
++ } finally { await f.close(); }
++ done();
++})();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 4e046c9..56500fa 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -48,8 +48,8 @@ async function startNetwork() {
const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
-- function addUser(username, { role = 'user' } = {}) {
-- const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ function addUser(username, { role = 'user', adult = false } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role, adult };
st.users.set(u.id, u); st.byUsername.set(username, u);
return u;
}
@@@ -118,6 +118,27 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
}
++ // Network's account flag (Network PR #587, server/identity/adult.js): the confirmation sits on the
++ // account row. A person confirms it on their own account; a service may read it with their token.
++ if (url.pathname === '/api/v1/me/adult' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { adult: Boolean(u.adult), confirmed_at: u.adult ? '2026-11-06T00:00:00.000Z' : null });
++ }
++ if (url.pathname === '/api/v1/me/adult' && req.method === 'PUT') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ u.adult = true;
++ return json(200, { adult: true, confirmed_at: '2026-11-06T00:00:00.000Z', confirmation_version: 1 });
++ }
++ if (url.pathname === '/internal/identity/adult' && req.method === 'GET') {
++ const subject = url.searchParams.get('subject_id') || '';
++ const u = [...st.users.values()].find((x) => x.subject === subject);
++ if (!u) return problem(json, 404, 'identity.subject_not_found', 'no subject for that id');
++ return json(200, { subject_id: subject, adult: Boolean(u.adult), confirmed_at: u.adult ? '2026-11-06T00:00:00.000Z' : null });
++ }
if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
@@@ -127,7 +148,7 @@ async function startNetwork() {
return {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
-- addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
++ addUser, userToken, user: (u) => st.users.get(u && u.id ? u.id : u), signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setDown(v) { st.down = v; },
close: srv.close,