openvibers/openvibe.exchange Commits

Companions: memory notes in M2, the Memory panel, lorebooks and the Locker (stages 3-4) (#90)

OP OpenVibers committed 7 days ago · 678913ac62 · 17 files, +2433 −48

* Companions: memory notes, lorebooks and the Locker (stages 3-4)

Stage 3: after every few turns the router's economy alias writes short memory notes into the person's M2 store, scope project:companions-<character>, with the hard floor on every note. A Memory page lists them, edits, pins, deletes and adds them; they join a turn pinned first, within a token budget, and the 'what the model saw' panel lists them. With Locker on, notes and chat history are sealed with the person's keys, opened in the browser and sent for one turn only; turning it off is refused while anything is sealed. Memory index and lorebooks are exported and erased with the account; M2 carries the note text itself.

Stage 4: lorebooks with keys, content and priority, attached to the person, a character or a chat. An entry joins when a key is in the recent turns, highest priority first, within a budget, and the saw panel lists what joined. SillyTavern World Info imports and exports; the floor holds on every entry.

Tests: test/companions-memory.test.js covers each rule; the full suite passes 9/9. Screenshots at 390 and 1280, dark and light, are local only.

* Memory and Lorebooks lead with the person's notes and books; forms and the Locker follow; kinds are capitalised

* Run CI

* Merge remote-tracking branch 'origin/main' into review-90

# Conflicts:
#	server/http/companions.js

Changes

ddiff --git a/migrations/0008_companion_memory.sql b/migrations/0008_companion_memory.sql
nnew file mode 100644
iindex 0000000..9e13098
---- /dev/null
++++ b/migrations/0008_companion_memory.sql
@@@ -0,0 +1,80 @@
++-- phase: expand
++-- Memory notes and lorebooks (Opus's design, 2026-11-06, stages 3 and 4). Expand-only: nothing here
++-- changes an existing table.
++--
++-- The note TEXT is not here: it lives in the person's OpenVibe memory, the M2 store in OpenVibe.AI
++-- (server/memory/index.js), under the scope project:companions-<character id>. This table is the index:
++-- one row per note, saying which character the note belongs to, what kind it is, whether the person
++-- pinned it, who wrote it, and which M2 item holds the text. The pin has no home in M2 (an item has a
++-- kind, a status and a source, but no pin), so the pin is ours.
++
++CREATE TABLE IF NOT EXISTS exchange_companion_notes (
++ id text COLLATE "C" PRIMARY KEY, -- exn_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ character_id text COLLATE "C" NOT NULL, -- exc_…: the character the notes are about
++ m2_id text COLLATE "C" NOT NULL, -- mem_…: the M2 item that holds the note's text
++ kind text COLLATE "C" NOT NULL, -- fact | preference | event
++ pinned boolean NOT NULL DEFAULT false, -- the person pinned it: it is read first
++ sealed boolean NOT NULL DEFAULT false, -- sealed with the person's Locker keys: OpenVibe cannot read it
++ origin text COLLATE "C" NOT NULL, -- model | person
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_companion_notes_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_companion_notes_kind CHECK (kind IN ('fact', 'preference', 'event')),
++ CONSTRAINT exchange_companion_notes_origin CHECK (origin IN ('model', 'person'))
++);
++CREATE UNIQUE INDEX IF NOT EXISTS exchange_companion_notes_owner_m2 ON exchange_companion_notes (owner, m2_id);
++CREATE INDEX IF NOT EXISTS exchange_companion_notes_character ON exchange_companion_notes (owner, character_id);
++
++-- A lorebook: a set of entries the person keeps. It hangs off a character, a chat, or the person
++-- themselves (their world).
++CREATE TABLE IF NOT EXISTS exchange_lorebooks (
++ id text COLLATE "C" PRIMARY KEY, -- exb_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ name text NOT NULL DEFAULT '',
++ scope text COLLATE "C" NOT NULL, -- character | chat | person
++ character_id text COLLATE "C", -- exc_… when the scope is 'character'
++ chat_id text COLLATE "C", -- exc_… when the scope is 'chat'
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_lorebooks_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_lorebooks_scope CHECK (scope IN ('character', 'chat', 'person'))
++);
++CREATE INDEX IF NOT EXISTS exchange_lorebooks_owner ON exchange_lorebooks (owner, scope);
++
++-- One entry: the keys that call it in, and what it says. The keys are a JSON array; the priority is
++-- the order the entries join the context in (the highest first).
++CREATE TABLE IF NOT EXISTS exchange_lorebook_entries (
++ id text COLLATE "C" PRIMARY KEY, -- exe_ + 26 Crockford base32 characters (ULID)
++ lorebook_id text COLLATE "C" NOT NULL, -- exb_…
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ name text NOT NULL DEFAULT '', -- what the entry is called, for the panel
++ keys text COLLATE "C" NOT NULL DEFAULT '[]', -- the keys, as a JSON array
++ content text NOT NULL DEFAULT '',
++ priority integer NOT NULL DEFAULT 0,
++ constant boolean NOT NULL DEFAULT false, -- always in the context (the World Info word for it)
++ enabled boolean NOT NULL DEFAULT true,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_lorebook_entries_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
++CREATE INDEX IF NOT EXISTS exchange_lorebook_entries_lorebook ON exchange_lorebook_entries (lorebook_id, priority DESC);
++
++-- The Locker, per person and character: when a character is locked, its notes and the chats of it are
++-- sealed with the person's own Locker keys, and OpenVibe cannot read them at rest. The fingerprint says which
++-- keys sealed it, so a key change is visible.
++CREATE TABLE IF NOT EXISTS exchange_companion_locks (
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ character_id text COLLATE "C" NOT NULL, -- exc_…
++ fingerprint text, -- the eight words of the key that sealed it
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (owner, character_id),
++ CONSTRAINT exchange_companion_locks_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
++
++-- The chat's own settings for the locked mode: when a chat is locked, its notes and its history are
++-- sealed with the person's Locker keys, and OpenVibe cannot read them at rest. The sealed bytes live in the
++-- message column (meta says so), not in a column of their own.
++ALTER TABLE exchange_companion_chats ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
++ALTER TABLE exchange_companion_chats ADD COLUMN IF NOT EXISTS locker_fingerprint text;
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 692b376..ea68839 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -459,3 +459,36 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-act { min-height: 44px; }
.ex-branch-arrow { min-width: 44px; min-height: 44px; }
}
++
++/* ── Companions: memory and lorebooks ─────────────────────────────────── */
++.ex-chat-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
++.ex-locked { margin: 0 0 8px; font-size: .88rem; color: var(--ovk-muted); }
++.ex-sealed { display: inline-block; padding: 2px 8px; border: 1px dashed var(--ovk-line); border-radius: 8px; color: var(--ovk-muted); font-style: italic; }
++.ex-memory-lock, .ex-memory-add, .ex-memory-list { margin: 22px 0; }
++.ex-memory-lock h2, .ex-memory-add h2, .ex-memory-list h2 { font-size: .78rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ovk-muted); margin: 0 0 8px; }
++.ex-notes, .ex-entries { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.ex-note, .ex-entry, .ex-lorebook { border: 1px solid var(--ovk-line); border-radius: 14px; padding: 12px 14px; background: var(--ovk-panel); }
++.ex-note-head, .ex-entry-head, .ex-lorebook-head { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; margin-bottom: 6px; }
++.ex-note-text, .ex-entry-text { margin: 0 0 8px; line-height: 1.55; overflow-wrap: anywhere; }
++.ex-note-acts { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
++.ex-entry-keys { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 6px; }
++.ex-lorebook { margin: 18px 0; }
++.ex-lorebook-head h2 { margin: 0; font-size: 1.05rem; }
++.ex-lorebook .ex-menu-body, .ex-note .ex-menu-body { margin-top: 10px; }
++.ex-menu-body input[type=text], .ex-menu-body input[type=number], .ex-menu-body select {
++ 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-menu-body input[type=file] { max-width: 100%; }
++.ex-check { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: .9rem; }
++.ex-check input { width: 20px; height: 20px; }
++.ex-saw-list { margin: 0; padding-left: 18px; font-size: .82rem; line-height: 1.5; }
++@media (max-width: 420px) {
++ .ex-note, .ex-entry, .ex-lorebook { padding: 10px 12px; }
++ .ex-note-acts .ex-act, .ex-note-acts .ex-act-form button { min-height: 44px; }
++}
++
++.ex-back { display: inline-flex; align-items: center; min-height: 44px; }
++.ex-menu-body input[type=file] { min-height: 44px; }
++.ex-memory-lock a { display: inline-flex; align-items: center; min-height: 44px; }
ddiff --git a/public/js/companions-locker.js b/public/js/companions-locker.js
nnew file mode 100644
iindex 0000000..8038e59
---- /dev/null
++++ b/public/js/companions-locker.js
@@@ -0,0 +1,152 @@
++/* OpenVibe.Exchange: the Locker, in the browser.
++ *
++ * A locked chat keeps its notes and its history sealed with the person's own keys (openvibe-shared's
++ * ov-locker.js opens them; openvibe-sdk/locker is the cryptography behind it). This file asks for the locker,
++ * opens every sealed thing on the page, and sends what it opened with the next message, for that turn only.
++ *
++ * What travels with the turn: the turns it opened (in order), the text of each message (so a regenerate can
++ * answer the same message), and the notes it opened. The server uses them to build this one answer and stores
++ * nothing in the clear.
++ *
++ * The page is complete without this file: a locked chat asks for the unlock, and says so.
++ */
++(function () {
++ 'use strict';
++ var doc = document;
++ var root = window;
++ var FIELD = 'locker_context';
++
++ function b64ToBytes(text) {
++ var bin = atob(String(text || ''));
++ var out = new Uint8Array(bin.length);
++ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
++ return out;
++ }
++ function bytesToText(bytes) {
++ if (typeof bytes === 'string') return bytes;
++ try { return new TextDecoder('utf-8').decode(bytes); } catch (e) { return String(bytes || ''); }
++ }
++
++ function sealedNodes() {
++ return Array.prototype.slice.call(doc.querySelectorAll('[data-locker-sealed]'));
++ }
++
++ /** What the browser opened: the history, the message texts, and the notes. */
++ function opened() {
++ var history = [];
++ var messages = {};
++ var notes = [];
++ Array.prototype.forEach.call(doc.querySelectorAll('[data-locker-opened]'), function (el) {
++ var text = el.getAttribute('data-locker-text') || '';
++ var role = el.getAttribute('data-locker-role');
++ var message = el.getAttribute('data-locker-message');
++ var note = el.getAttribute('data-locker-note');
++ if (role === 'user' || role === 'assistant') history.push({ role: role, content: text });
++ if (message) messages[message] = text;
++ if (note) {
++ notes.push({
++ id: note,
++ m2_id: note,
++ kind: el.getAttribute('data-locker-kind') || 'fact',
++ pinned: el.getAttribute('data-locker-pinned') === '1',
++ text: text,
++ });
++ }
++ });
++ return { history: history, messages: messages, notes: notes };
++ }
++
++ function fill(el, text) {
++ el.removeAttribute('data-locker-sealed');
++ el.setAttribute('data-locker-opened', '1');
++ el.setAttribute('data-locker-text', text);
++ el.textContent = text;
++ }
++
++ function openOne(el) {
++ var locker = root.OVLocker;
++ if (!locker) return Promise.resolve(null);
++ var bytes = b64ToBytes(el.getAttribute('data-locker-sealed'));
++ var holder = doc.createElement('div');
++ holder.style.display = 'none';
++ doc.body.appendChild(holder);
++ return locker.open(holder, {
++ bytes: bytes,
++ onOpen: function (plain) { fill(el, bytesToText(plain)); },
++ }).then(function (plain) {
++ if (plain) fill(el, bytesToText(plain));
++ holder.remove();
++ return plain;
++ }).catch(function () { holder.remove(); return null; });
++ }
++
++ function field() {
++ var form = doc.getElementById('ovt-composer');
++ if (!form) return null;
++ var f = doc.getElementById('ex-locker-context');
++ if (!f) {
++ f = doc.createElement('input');
++ f.type = 'hidden';
++ f.id = 'ex-locker-context';
++ f.name = FIELD;
++ form.appendChild(f);
++ }
++ return f;
++ }
++
++ function sendContext() {
++ var f = field();
++ if (!f) return;
++ f.value = JSON.stringify(opened());
++ }
++
++ function start() {
++ var nodes = sealedNodes();
++ var lockedPage = Boolean(doc.querySelector('[data-locker-chat]'));
++ var locker = root.OVLocker;
++ if (!locker || (!nodes.length && !lockedPage)) return;
++ var form = doc.getElementById('ovt-composer');
++ var button = doc.getElementById('ex-locker-unlock');
++ // One gate for every submit: while a sealed turn is still closed, the form does not go (the history
++ // would be lost); once every node is open, the bundle travels with the message, for that turn only.
++ // A locked chat with no sealed turn yet (the first message) sends the empty bundle.
++ if (form) {
++ form.addEventListener('submit', function (event) {
++ if (sealedNodes().length) {
++ event.preventDefault();
++ if (button) button.removeAttribute('hidden');
++ return;
++ }
++ sendContext();
++ });
++ }
++ if (!nodes.length) return;
++ var open = function () {
++ return Promise.all(nodes.map(openOne)).then(function () {
++ if (button) button.setAttribute('hidden', 'hidden');
++ });
++ };
++ if (locker.state && locker.state().unlocked) { open(); return; }
++ if (!button) {
++ button = doc.createElement('button');
++ button.type = 'button';
++ button.id = 'ex-locker-unlock';
++ button.className = 'ex-btn ex-btn-primary';
++ button.textContent = 'Unlock your locker';
++ var note = doc.getElementById('ex-locker-note');
++ (note || doc.body).appendChild(button);
++ }
++ button.removeAttribute('hidden');
++ button.addEventListener('click', function () {
++ button.setAttribute('hidden', 'hidden');
++ locker.unlock().then(function (st) {
++ if (st && st.unlocked) return open();
++ button.removeAttribute('hidden');
++ return null;
++ });
++ });
++ }
++
++ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start);
++ else start();
++})();
ddiff --git a/scripts/shoot-memory-lorebooks.js b/scripts/shoot-memory-lorebooks.js
nnew file mode 100644
iindex 0000000..20c166d
---- /dev/null
++++ b/scripts/shoot-memory-lorebooks.js
@@@ -0,0 +1,160 @@
++'use strict';
++/**
++ * Local screenshots of the Memory and Lorebook panels (stages 3 and 4), for Opus's design review.
++ *
++ * The images go to a temp folder, never into the repository and never to a server:
++ * node scripts/shoot-memory-lorebooks.js [outDir]
++ *
++ * For each page it checks the width (scrollWidth === viewport), the console, and the tap targets at 390 px.
++ * The M2 store is a stand-in here: the shots are of the pages, not of OpenVibe.AI.
++ */
++const path = require('path');
++const os = require('os');
++const fs = require('fs');
++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-memory-shots'));
++const CHROME = '/usr/bin/google-chrome';
++
++/** A tiny M2 store: the items the memory panel reads and writes. */
++function fakeMemory() {
++ const items = new Map();
++ const view = (it) => ({ id: it.id, scope: it.scope, kind: it.kind, text: it.text, source: it.source, sources: [it.source], source_ref: it.source_ref || null, status: 'active', confidence: 1, seen_at: it.updated_at, created_at: it.created_at, updated_at: it.updated_at, merged_into: null, conflict_with: null });
++ let n = 0;
++ const reply = (status, body) => ({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body });
++ return {
++ items,
++ async fetchImpl(url, options = {}) {
++ const u = new URL(String(url));
++ const method = String(options.method || 'GET').toUpperCase();
++ const body = options.body ? JSON.parse(String(options.body)) : null;
++ if (u.pathname === '/api/v1/memory/items' && method === 'GET') {
++ const scope = u.searchParams.get('scope');
++ const list = [...items.values()].filter((it) => !scope || it.scope === scope).map(view);
++ return reply(200, { items: list, count: list.length });
++ }
++ if (u.pathname === '/api/v1/memory/items:batch' && method === 'POST') {
++ const out = [];
++ for (const raw of body.items || []) {
++ const at = new Date().toISOString();
++ const item = { id: `mem_${String(++n).padStart(4, '0')}`, scope: raw.scope, kind: raw.kind, text: raw.text, source: raw.source, source_ref: raw.source_ref || null, created_at: at, updated_at: at };
++ items.set(item.id, item); out.push(view(item));
++ }
++ return reply(200, { items: out, count: out.length, created: out.length, deduped: 0 });
++ }
++ const one = /^\/api\/v1\/memory\/items\/([^/]+)$/.exec(u.pathname);
++ if (one && method === 'PATCH') { const it = items.get(decodeURIComponent(one[1])); if (it && body.text != null) it.text = body.text; return reply(200, { item: view(it) }); }
++ if (one && method === 'DELETE') { items.delete(decodeURIComponent(one[1])); return reply(200, { deleted: 1 }); }
++ return reply(404, { detail: 'not found' });
++ },
++ };
++}
++
++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?',
++ tags: 'analytical, history',
++};
++
++(async () => {
++ fs.mkdirSync(OUT, { recursive: true });
++ const memory = fakeMemory();
++ const ai = createFakeAi({
++ chat(args) {
++ if (args.model === 'openvibe/economy') return { content: '[{"kind":"fact","text":"The person is called Ada and likes clocks."}]', model: 'openvibe/economy', cost_usd: 0.000001, tokens_in: 40, tokens_out: 18, latency_ms: 12 };
++ return { content: 'The engine will sing, and we shall listen to it together.', model: args.model, provider: 'deepseek', run_id: 'run_SHOT', cost_usd: 0.00012, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: ['cheapest healthy offer within your ceiling'] };
++ },
++ });
++ const t = await boot({ ai, aiFetchImpl: memory.fetchImpl, env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '2' } });
++ const alice = t.network.addUser('alice');
++ const card = await t.ctx.characters.create({ owner: alice.subject, handle: 'alice', ...FORM });
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'fact', text: 'The person keeps a notebook of clock designs.', pinned: true }, alice.subject);
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'preference', text: 'Prefers short answers, and a question at the end.', pinned: false }, alice.subject);
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'event', text: 'They met in her London study and talked about the analytical engine for an hour.', pinned: false }, alice.subject);
++
++ const book = await t.ctx.lorebooks.create(alice.subject, { name: 'The clock world', scope: 'person' });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { name: 'The engine', keys: 'engine, machine', content: 'The analytical engine computes numbers with punched cards. It has never been built.', priority: 8 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { name: 'The study', keys: 'study, London', content: 'Her study is warm, full of paper, and the window looks onto the street.', priority: 4 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { name: 'The rule of the house', content: 'Music is allowed at any hour.', constant: true, priority: 2 });
++
++ const talk = await t.get('/c/alice/ada-lovelace/talk', { as: alice, method: 'POST' });
++ const chatUrl = talk.headers.get('location');
++ await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'Tell me about the engine in your study.' } });
++ await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'Do you remember what I like?' } });
++
++ const cookie = t.signIn(alice);
++ const browser = await chromium.launch({ executablePath: CHROME });
++ const problems = [];
++
++ async function capture(shots) {
++ 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(t.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();
++ 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(t.base + shot.path, { waitUntil: 'load' });
++ await pg.waitForTimeout(500);
++ if (shot.openPanel) {
++ await pg.evaluate(() => { const b = document.querySelector('[data-ovt-panel-open]'); if (b) b.click(); });
++ await pg.waitForTimeout(300);
++ }
++ 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')) {
++ 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;
++ const st = getComputedStyle(el);
++ if (st.opacity === '0' || st.visibility === 'hidden' || el.closest('.ovk-sr, .sr-only, .visually-hidden')) continue;
++ // A checkbox or a file picker is tapped through its label, which is the finger-sized target.
++ if (el.matches('input[type=checkbox], input[type=file], input[type=hidden]')) continue;
++ if (r.height < 44) out.push(el.tagName + ' [' + String(el.textContent || '').trim().slice(0, 18) + '] 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();
++ }
++ }
++ }
++
++ // The plain pages first: the notes as text, the lorebook with its entries, the saw panel.
++ await capture([
++ { name: 'memory', path: '/c/alice/ada-lovelace/memory' },
++ { name: 'lorebook', path: '/c/alice/ada-lovelace/lorebook?chat=' + chatUrl.split('/').pop() },
++ { name: 'chat-saw', path: chatUrl, openPanel: true },
++ ]);
++
++ // With the Locker on, the same pages carry the envelopes, and the chat asks for the unlock.
++ await t.network.giveLocker(alice);
++ const locked = await t.get('/c/alice/ada-lovelace/lock', { as: alice, method: 'POST', form: { on: '1' } });
++ if (locked.status !== 200) throw new Error('the Locker did not turn on: ' + locked.status);
++ await capture([
++ { name: 'chat-locked', path: chatUrl },
++ { name: 'memory-locked', path: '/c/alice/ada-lovelace/memory' },
++ ]);
++ await browser.close();
++ await t.close();
++ process.stdout.write('shots in ' + OUT + String.fromCharCode(10));
++ 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);
++})().catch(async (err) => { process.stdout.write('FAILED: ' + (err.stack || err) + String.fromCharCode(10)); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex bd07c61..e2920db 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,6 +33,10 @@ const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
const { createSharing } = require('./companions/sharing');
const { createAdultAccount } = require('./companions/adult');
++const { createMemoryClient, createCompanionNotes } = require('./companions/memory');
++const { createLorebooks } = require('./companions/lorebook');
++const { createLocker } = require('./companions/locker');
++const { createNoteTaker } = require('./companions/taking-notes');
const { createPageRoutes } = require('./http/pages');
const { createCompanionRoutes } = require('./http/companions');
const { createServiceReadiness } = require('./observability');
@@@ -72,6 +76,14 @@ async function createApp(opts = {}) {
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 });
++ // The memory notes (stage 3): the index here, the text in the person's own memory (OpenVibe.AI's M2 store),
++ // read and written with the person's own session token. The lorebook (stage 4) is ours, and the Locker
++ // (openvibe-sdk/locker) seals what the person asks to be sealed.
++ const memory = createMemoryClient({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log });
++ const notes = createCompanionNotes({ db: s.db, memory, config, log });
++ const lorebooks = createLorebooks({ db: s.db, config, log });
++ const locker = createLocker({ config, fetchImpl, log });
++ const noteTaker = createNoteTaker({ ai, notes, config, log });
// 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.
@@@ -81,7 +93,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, adultAccount, characters, companionChats, sharing, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, ai, openapi, chats, adult, adultAccount, characters, companionChats, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/companions/locker.js b/server/companions/locker.js
nnew file mode 100644
iindex 0000000..b178fe4
---- /dev/null
++++ b/server/companions/locker.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * The Locker (Opus's design, 2026-11-06, stage 3): the person's own keys seal their notes and their chat
++ * history, so OpenVibe cannot read them at rest.
++ *
++ * How the keys travel: the person's Locker lives in their browser (openvibe-shared's ov-locker.js), and its
++ * public halves are in Network's key directory (GET /api/v1/people/:subject/keys - Network PR #577). Exchange
++ * reads the public key with the person's own session token, the same token the 18+ flag is read with
++ * (server/companions/adult.js), and seals with openvibe-sdk/locker. The private half never comes here: the
++ * browser opens what Exchange sealed, and sends what it opened with the request, for that turn only.
++ *
++ * The rules this file keeps:
++ * - only a person with a Locker can be sealed for (no key, no seal);
++ * - what is sealed is sealed with the person's ECDH public key, in a people lock, so only their key opens it;
++ * - the fingerprint of the key is kept with the chat, so a key change is visible.
++ */
++
++const locker = require('openvibe-sdk/locker');
++
++const b64 = (bytes) => Buffer.from(bytes).toString('base64');
++const unb64 = (text) => new Uint8Array(Buffer.from(String(text || ''), 'base64'));
++
++/** The people slot of an envelope: the subject it names and the key it was made for. */
++function subjectOf(subject) {
++ return { type: 'user', id: String(subject || '') };
++}
++
++function createLocker({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config && config.networkUrl) || '').replace(/\/+$/, '');
++ const timeoutMs = 15_000;
++
++ /**
++ * The person's public Locker keys, read from Network's key directory with the person's own token.
++ * A person without a Locker has no keys: null is the answer, and nothing is sealed for them.
++ */
++ async function keysFor(subject, token) {
++ if (!subject || !token) return null;
++ let res;
++ try {
++ res = await fetchImpl(base + '/api/v1/people/' + encodeURIComponent(String(subject)) + '/keys', {
++ headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ log.warn('[Companions] the locker keys could not be read:', String((err && err.message) || err).slice(0, 200));
++ return null;
++ }
++ if (res.status === 404) return null; // no locker yet
++ if (!res.ok) {
++ log.warn('[Companions] the locker keys answered ' + res.status);
++ return null;
++ }
++ let body = null;
++ try { body = await res.json(); } catch (e) { body = null; }
++ if (!body || !body.ecdh_public) return null;
++ return {
++ subject: String(body.subject || subject),
++ ecdh: body.ecdh_public,
++ signing: body.signing_public || null,
++ fingerprint: body.fingerprint || null,
++ };
++ }
++
++ /** Seal a text for the person: a people lock, only their key opens it. */
++ async function seal(text, keys) {
++ if (!keys || !keys.ecdh) {
++ const err = new Error('this person has no locker keys');
++ err.code = 'locker.no_keys';
++ err.status = 409;
++ throw err;
++ }
++ const sealed = await locker.seal(String(text == null ? '' : text), {
++ lock: 'people',
++ keys: [{ subject: subjectOf(keys.subject), publicKey: keys.ecdh }],
++ });
++ return b64(sealed.bytes);
++ }
++
++ /** Open a sealed text with a private key: the browser does this, the tests do it too. */
++ async function open(text, person) {
++ const plain = await locker.open(unb64(text), { person });
++ return Buffer.from(plain).toString('utf8');
++ }
++
++ /** What an envelope says about itself, without a key. */
++ function inspect(text) {
++ try { return locker.inspect(unb64(text)); } catch (e) { return null; }
++ }
++
++ return { keysFor, seal, open, inspect, subjectOf };
++}
++
++module.exports = { createLocker, subjectOf };
ddiff --git a/server/companions/lorebook.js b/server/companions/lorebook.js
nnew file mode 100644
iindex 0000000..e9c6d23
---- /dev/null
++++ b/server/companions/lorebook.js
@@@ -0,0 +1,352 @@
++'use strict';
++/**
++ * Lorebooks (Opus's design, 2026-11-06, stage 4).
++ *
++ * A lorebook is a set of entries the person keeps: the keys that call an entry in, and what the entry
++ * says. It hangs off a character, off a chat, or off the person themselves (their world).
++ *
++ * How an entry joins the context: when one of its keys appears in the recent turns, the entry's
++ * content joins the context, within a token budget, the highest priority first. Nothing hidden: the
++ * chat's "what the model saw" panel lists the entries used for each answer.
++ *
++ * The open format: SillyTavern World Info, in and out. The import takes the file SillyTavern writes
++ * (`{"entries": {"0": {"key": [...], "content": "...", ...}}}`) and the export writes the same shape,
++ * so a lorebook can go to SillyTavern and come back.
++ *
++ * The hard floor holds on entries like on everything else: an entry that breaks it is refused at save.
++ */
++
++const floor = require('./floor');
++
++const SCOPES = ['character', 'chat', 'person'];
++const MAX_KEYS = 24;
++const MAX_KEY_LENGTH = 80;
++const MAX_CONTENT = 8000;
++
++/** The token cost of a text, the way the platform counts them: about four characters to a token. */
++const tokensOf = (s) => Math.ceil(String(s == null ? '' : s).length / 4);
++
++const parseJson = (v, fallback) => { try { return v ? JSON.parse(v) : fallback; } catch (e) { return fallback; } };
++
++/** The keys of an entry, as an array of short strings. */
++function keysOf(value) {
++ const list = Array.isArray(value) ? value : String(value == null ? '' : value).split(',');
++ return [...new Set(list.map((k) => String(k == null ? '' : k).trim().toLowerCase()).filter(Boolean))]
++ .slice(0, MAX_KEYS)
++ .map((k) => k.slice(0, MAX_KEY_LENGTH));
++}
++
++/**
++ * The World Info shape: the file SillyTavern writes and reads. Every field it uses is kept, so a
++ * lorebook that goes out and comes back is the same lorebook.
++ */
++function toWorldInfo(books) {
++ const entries = {};
++ let uid = 0;
++ for (const book of books || []) {
++ for (const entry of book.entries || []) {
++ entries[String(uid)] = {
++ uid,
++ key: (entry.keys || []).slice(),
++ keysecondary: [],
++ comment: entry.name || book.name || '',
++ content: entry.content || '',
++ constant: entry.constant === true,
++ selective: true,
++ selectiveLogic: 0,
++ order: Number(entry.priority) || 0,
++ position: 'before_char',
++ disable: entry.enabled === false,
++ probability: 100,
++ useProbability: true,
++ depth: 4,
++ group: '',
++ scanDepth: null,
++ caseSensitive: null,
++ matchWholeWords: null,
++ automationId: null,
++ role: 0,
++ excludeRecursion: false,
++ preventRecursion: false,
++ delayUntilRecursion: false,
++ sticky: 0,
++ cooldown: 0,
++ delay: 0,
++ };
++ uid++;
++ }
++ }
++ return { entries };
++}
++
++/**
++ * Read a World Info file. Both shapes are accepted: the object SillyTavern writes
++ * (`{"entries": {"0": {...}}}`) and a plain array of entries.
++ */
++function fromWorldInfo(text) {
++ let raw = text;
++ if (typeof raw === 'string') {
++ try { raw = JSON.parse(raw); } catch (e) {
++ const err = new Error('that is not a World Info file');
++ err.code = 'lorebook.bad_file';
++ err.status = 422;
++ throw err;
++ }
++ }
++ const list = Array.isArray(raw) ? raw : (raw && raw.entries && !Array.isArray(raw.entries)
++ ? Object.values(raw.entries) : (raw && Array.isArray(raw.entries) ? raw.entries : []));
++ const out = [];
++ for (const entry of list) {
++ if (!entry || typeof entry !== 'object') continue;
++ const keys = keysOf(entry.key || entry.keys || []);
++ const content = String(entry.content == null ? '' : entry.content).slice(0, MAX_CONTENT);
++ if (!content) continue;
++ out.push({
++ name: String(entry.comment || entry.name || '').slice(0, 120),
++ keys,
++ content,
++ priority: Number.isFinite(Number(entry.order)) ? Number(entry.order) : (Number(entry.priority) || 0),
++ constant: entry.constant === true,
++ enabled: entry.disable !== true,
++ });
++ }
++ if (!out.length) {
++ const err = new Error('that file has no entries');
++ err.code = 'lorebook.empty_file';
++ err.status = 422;
++ throw err;
++ }
++ return out;
++}
++
++function createLorebooks({ db, config, log = console }) {
++ /** A book of the person, by id. */
++ async function book(owner, id) {
++ const row = await db.prepare('SELECT * FROM exchange_lorebooks WHERE id = ? AND owner = ?').get(String(id), owner);
++ return row || null;
++ }
++
++ /** The entries of a book, the highest priority first. */
++ async function entriesOf(owner, lorebookId) {
++ const rows = await db.prepare('SELECT * FROM exchange_lorebook_entries WHERE lorebook_id = ? AND owner = ? ORDER BY priority DESC, created_at ASC')
++ .all(String(lorebookId), owner);
++ return rows.map((r) => ({ ...r, keys: keysOf(parseJson(r.keys, [])) }));
++ }
++
++ /** Make a book. */
++ async function create(owner, { name = '', scope = 'person', characterId = null, chatId = null } = {}) {
++ const s = SCOPES.includes(scope) ? scope : 'person';
++ const id = 'exb_' + require('openvibe-contracts').ids.ulid();
++ await db.prepare('INSERT INTO exchange_lorebooks (id, owner, name, scope, character_id, chat_id) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(id, owner, String(name || '').slice(0, 120), s, s === 'character' ? String(characterId) : null, s === 'chat' ? String(chatId) : null);
++ return book(owner, id);
++ }
++
++ /**
++ * The books that apply to a chat: the person's own (their world), the character's, and the chat's.
++ */
++ async function booksFor(owner, { characterId = null, chatId = null } = {}) {
++ const rows = await db.prepare("SELECT * FROM exchange_lorebooks WHERE owner = ? AND (scope = 'person' OR (scope = 'character' AND character_id = ?) OR (scope = 'chat' AND chat_id = ?)) ORDER BY created_at ASC")
++ .all(owner, characterId ? String(characterId) : '', chatId ? String(chatId) : '');
++ const out = [];
++ for (const row of rows) {
++ out.push({ ...row, entries: await entriesOf(owner, row.id) });
++ }
++ return out;
++ }
++
++ /** Add an entry to a book. */
++ async function addEntry(owner, lorebookId, { keys = [], content = '', priority = 0, constant = false, name = '' } = {}) {
++ const bookRow = await book(owner, lorebookId);
++ if (!bookRow) {
++ const err = new Error('no such lorebook');
++ err.code = 'lorebook.not_found';
++ err.status = 404;
++ throw err;
++ }
++ const body = String(content == null ? '' : content).slice(0, MAX_CONTENT);
++ const ks = keysOf(keys);
++ const label = String(name == null ? '' : name).slice(0, 120);
++ if (!body) {
++ const err = new Error('an entry needs some content');
++ err.code = 'lorebook.empty';
++ err.status = 422;
++ throw err;
++ }
++ if (!ks.length && !constant) {
++ const err = new Error('an entry needs at least one key');
++ err.code = 'lorebook.no_keys';
++ err.status = 422;
++ throw err;
++ }
++ // The hard floor holds on entries like on everything else.
++ const check = floor.check([body, label, ...ks]);
++ if (!check.ok) {
++ const err = new Error('that entry is refused: ' + check.detail + ' (' + check.label + ')');
++ err.code = 'lorebook.floor';
++ err.status = 422;
++ err.rule = check.rule;
++ throw err;
++ }
++ const id = 'exe_' + require('openvibe-contracts').ids.ulid();
++ await db.prepare('INSERT INTO exchange_lorebook_entries (id, lorebook_id, owner, name, keys, content, priority, constant, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, true)')
++ .run(id, String(lorebookId), owner, label, JSON.stringify(ks), body, Number(priority) || 0, constant === true);
++ return { id, lorebook_id: String(lorebookId), name: label, keys: ks, content: body, priority: Number(priority) || 0, constant: constant === true, enabled: true };
++ }
++
++ /** Change an entry. */
++ async function editEntry(owner, entryId, patch = {}) {
++ const row = await db.prepare('SELECT * FROM exchange_lorebook_entries WHERE id = ? AND owner = ?').get(String(entryId), owner);
++ if (!row) {
++ const err = new Error('no such entry');
++ err.code = 'lorebook.not_found';
++ err.status = 404;
++ throw err;
++ }
++ const sets = [];
++ const values = [];
++ const next = {
++ name: 'name' in patch ? String(patch.name == null ? '' : patch.name).slice(0, 120) : row.name,
++ content: 'content' in patch ? String(patch.content == null ? '' : patch.content).slice(0, MAX_CONTENT) : row.content,
++ keys: 'keys' in patch ? keysOf(patch.keys) : keysOf(parseJson(row.keys, [])),
++ constant: 'constant' in patch ? patch.constant === true : row.constant === true,
++ };
++ if (!next.content) {
++ const err = new Error('an entry needs some content');
++ err.code = 'lorebook.empty';
++ err.status = 422;
++ throw err;
++ }
++ if (!next.keys.length && !next.constant) {
++ const err = new Error('an entry needs at least one key');
++ err.code = 'lorebook.no_keys';
++ err.status = 422;
++ throw err;
++ }
++ const check = floor.check([next.content, next.name, ...next.keys]);
++ if (!check.ok) {
++ const err = new Error('that entry is refused: ' + check.detail + ' (' + check.label + ')');
++ err.code = 'lorebook.floor';
++ err.status = 422;
++ err.rule = check.rule;
++ throw err;
++ }
++ if ('name' in patch) {
++ sets.push('name = ?');
++ values.push(next.name);
++ }
++ if ('content' in patch) {
++ sets.push('content = ?');
++ values.push(next.content);
++ }
++ if ('keys' in patch) {
++ sets.push('keys = ?');
++ values.push(JSON.stringify(next.keys));
++ }
++ if ('priority' in patch) {
++ sets.push('priority = ?');
++ values.push(Number(patch.priority) || 0);
++ }
++ if ('constant' in patch) {
++ sets.push('constant = ?');
++ values.push(patch.constant === true);
++ }
++ if ('enabled' in patch) {
++ sets.push('enabled = ?');
++ values.push(patch.enabled !== false);
++ }
++ if (sets.length) {
++ await db.prepare('UPDATE exchange_lorebook_entries SET ' + sets.join(', ') + ', updated_at = now() WHERE id = ? AND owner = ?')
++ .run(...values, row.id, owner);
++ }
++ const after = await db.prepare('SELECT * FROM exchange_lorebook_entries WHERE id = ? AND owner = ?').get(row.id, owner);
++ return { ...after, keys: keysOf(parseJson(after.keys, [])) };
++ }
++
++ /** Delete an entry, or a whole book. */
++ async function removeEntry(owner, entryId) {
++ const row = await db.prepare('SELECT * FROM exchange_lorebook_entries WHERE id = ? AND owner = ?').get(String(entryId), owner);
++ if (!row) {
++ const err = new Error('no such entry');
++ err.code = 'lorebook.not_found';
++ err.status = 404;
++ throw err;
++ }
++ await db.prepare('DELETE FROM exchange_lorebook_entries WHERE id = ? AND owner = ?').run(row.id, owner);
++ return { id: row.id };
++ }
++
++ async function removeBook(owner, lorebookId) {
++ const bookRow = await book(owner, lorebookId);
++ if (!bookRow) {
++ const err = new Error('no such lorebook');
++ err.code = 'lorebook.not_found';
++ err.status = 404;
++ throw err;
++ }
++ await db.prepare('DELETE FROM exchange_lorebook_entries WHERE lorebook_id = ? AND owner = ?').run(bookRow.id, owner);
++ await db.prepare('DELETE FROM exchange_lorebooks WHERE id = ? AND owner = ?').run(bookRow.id, owner);
++ return { id: bookRow.id };
++ }
++
++ /** Import a World Info file into a book: the entries keep their keys, content and order. */
++ async function importWorldInfo(owner, lorebookId, text) {
++ const entries = fromWorldInfo(text);
++ const made = [];
++ for (const entry of entries) {
++ try {
++ made.push(await addEntry(owner, lorebookId, entry));
++ } catch (err) {
++ log.warn('[Companions] a World Info entry was refused:', String((err && err.message) || err).slice(0, 160));
++ }
++ }
++ if (!made.length) {
++ const err = new Error('none of the entries could be read');
++ err.code = 'lorebook.empty_file';
++ err.status = 422;
++ throw err;
++ }
++ return made;
++ }
++
++ /**
++ * The entries that join a chat's context: an entry whose key appears in the recent turns (or an
++ * entry that is always in), the highest priority first, within a token budget.
++ *
++ * The order is across every book, not book by book: a high-priority entry of the person's world
++ * wins over a low-priority one of the character. A tie goes to the older entry, so the same
++ * conversation always builds the same context. An entry that does not fit is left out, never cut.
++ */
++ function forContext(books, recentText, { budgetTokens = 1200 } = {}) {
++ const haystack = String(recentText || '').toLowerCase();
++ const candidates = [];
++ for (const book of books || []) {
++ for (const entry of book.entries || []) {
++ if (entry.enabled === false) continue;
++ const hit = entry.constant === true
++ || (entry.keys || []).some((k) => k && haystack.includes(String(k).toLowerCase()));
++ if (!hit) continue;
++ candidates.push({ ...entry, lorebook_id: book.id, lorebook_name: book.name });
++ }
++ }
++ candidates.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)
++ || String(a.created_at || '').localeCompare(String(b.created_at || ''))
++ || String(a.id || '').localeCompare(String(b.id || '')));
++ const used = [];
++ let usedTokens = 0;
++ for (const entry of candidates) {
++ const cost = tokensOf(entry.content);
++ if (usedTokens + cost > budgetTokens) continue;
++ used.push(entry);
++ usedTokens += cost;
++ }
++ return { entries: used, budgetTokens, usedTokens };
++ }
++
++ return {
++ book, entriesOf, create, booksFor, addEntry, editEntry, removeEntry, removeBook,
++ importWorldInfo, forContext, toWorldInfo, fromWorldInfo, keysOf, SCOPES,
++ };
++}
++
++module.exports = { createLorebooks, toWorldInfo, fromWorldInfo, keysOf, tokensOf, SCOPES };
ddiff --git a/server/companions/memory.js b/server/companions/memory.js
nnew file mode 100644
iindex 0000000..1ecadad
---- /dev/null
++++ b/server/companions/memory.js
@@@ -0,0 +1,302 @@
++'use strict';
++/**
++ * The memory notes of the companions (Opus's design, 2026-11-06, stage 3).
++ *
++ * Where the notes live: in the person's OpenVibe memory, the M2 store in OpenVibe.AI
++ * (server/memory/index.js). Exchange keeps no copy of the text: the note IS an M2 item, and the panel
++ * reads it back from there with the person's own session token - the same token the 18+ flag is read
++ * with (server/companions/adult.js). M2 seals the text at rest with the platform key and binds it to
++ * its owner, its id and its scope, so a row moved to another person or another scope does not open.
++ *
++ * The scope: the design says the scope is "companions/<character>". M2's scope grammar is
++ * `user` or `project:<slug>` - no slashes, lower case only - so a character's notes live in the scope
++ * `project:companions-<character id>` (the id lower-cased). It is the scope the design means.
++ *
++ * What Exchange keeps: the index. One row per note - which character, which kind, whether the person
++ * pinned it, who wrote it - and the id of the M2 item that holds the text. A pin has no home in M2 (an
++ * item has a kind, a status and a source, but no pin), so the pin is ours.
++ *
++ * The rules this file keeps:
++ * - a note is short, is one of the three kinds, and is checked against the hard floor before it is
++ * written: the floor holds on notes like on everything else;
++ * - the notes that join a chat's context are read within a token budget, pinned ones first;
++ * - M2 merges two notes that say the same thing (the older row is archived and points at the
++ * winner), so the index follows the merge instead of pointing at a row that is gone.
++ */
++
++const floor = require('./floor');
++
++const KINDS = ['fact', 'preference', 'event'];
++// The M2 kinds. An event is a fact about the person's life, so it is stored as a fact; the kind the
++// person reads (fact, preference, event) is in the index and in the note's source_ref.
++const M2_KINDS = { fact: 'fact', preference: 'preference', event: 'fact' };
++const SCOPE_RE = /^project:companions-[a-z0-9._-]{1,64}$/;
++const MAX_TEXT = 2000;
++
++/** The M2 scope that holds a character's notes. */
++function scopeFor(characterId) {
++ const id = String(characterId || '').toLowerCase();
++ const scope = 'project:companions-' + id;
++ if (!SCOPE_RE.test(scope)) throw new Error('that character has no notes scope');
++ return scope;
++}
++
++/** The token cost of a text, the way the platform counts them: about four characters to a token. */
++const tokensOf = (s) => Math.ceil(String(s == null ? '' : s).length / 4);
++
++/**
++ * The client for the M2 store. Every call carries the person's own token: M2 lets a person act only
++ * for themselves, and Exchange holds that token (it is the session token this site already forwards
++ * everywhere else). A service token may only search and suggest there, never browse or edit.
++ */
++function createMemoryClient({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config && config.ai && config.ai.url) || '').replace(/\/+$/, '');
++ const timeoutMs = (config && config.ai && config.ai.timeoutMs) || 30_000;
++
++ async function call(token, method, path, body) {
++ if (!token) {
++ const err = new Error('the person has to sign in to read their memory');
++ err.code = 'memory.no_token';
++ err.status = 401;
++ throw err;
++ }
++ const res = await fetchImpl(base + path, {
++ method,
++ headers: {
++ Authorization: 'Bearer ' + token,
++ Accept: 'application/json',
++ ...(body ? { 'Content-Type': 'application/json' } : {}),
++ },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ const raw = await res.text();
++ let out = null;
++ try { out = raw ? JSON.parse(raw) : null; } catch (e) { out = null; }
++ if (!res.ok) {
++ const err = new Error((out && (out.detail || out.title)) || ('the memory store answered ' + res.status));
++ err.status = res.status;
++ err.code = (out && out.code) || 'memory.failed';
++ throw err;
++ }
++ return out;
++ }
++
++ return {
++ /** The items of one scope: the person's own notes for a character. */
++ list: (token, scope) => call(token, 'GET', '/api/v1/memory/items?scope=' + encodeURIComponent(scope)),
++ /** Write items: the batch import. */
++ write: (token, items) => call(token, 'POST', '/api/v1/memory/items:batch', { items }),
++ /** Change one item's text. */
++ patch: (token, id, body) => call(token, 'PATCH', '/api/v1/memory/items/' + encodeURIComponent(id), body),
++ /** Delete one item. */
++ remove: (token, id) => call(token, 'DELETE', '/api/v1/memory/items/' + encodeURIComponent(id)),
++ };
++}
++
++/**
++ * The notes: the index here, the text in M2.
++ */
++function createCompanionNotes({ db, memory, config, log = console }) {
++ /** The index rows of one character, newest first. */
++ async function indexRows(owner, characterId) {
++ return await db.prepare('SELECT * FROM exchange_companion_notes WHERE owner = ? AND character_id = ? ORDER BY created_at DESC')
++ .all(owner, String(characterId));
++ }
++
++ /**
++ * The notes of a character: the M2 items of its scope, joined with the index. An M2 row that was
++ * merged into another one (the same thing said twice) is followed to the winner, and the index is
++ * corrected, so the person never sees a note that is not there.
++ */
++ async function list(owner, characterId, token) {
++ const scope = scopeFor(characterId);
++ const body = await memory.list(token, scope);
++ const items = (body && body.items) || [];
++ const byId = new Map(items.map((it) => [it.id, it]));
++ const rows = await indexRows(owner, characterId);
++ const out = [];
++ const seen = new Set();
++ for (const row of rows) {
++ let item = byId.get(row.m2_id) || null;
++ if (item && item.merged_into) {
++ const winner = byId.get(item.merged_into) || null;
++ if (winner) {
++ item = winner;
++ await db.prepare('UPDATE exchange_companion_notes SET m2_id = ?, updated_at = now() WHERE id = ? AND owner = ?')
++ .run(item.id, row.id, owner);
++ }
++ }
++ if (!item) {
++ // The note is gone from the store (the person deleted it in another place): the index
++ // row goes with it, so the panel tells the truth.
++ await db.prepare('DELETE FROM exchange_companion_notes WHERE id = ? AND owner = ?').run(row.id, owner);
++ continue;
++ }
++ if (seen.has(item.id)) continue;
++ seen.add(item.id);
++ out.push({
++ id: row.id,
++ m2_id: item.id,
++ text: item.text,
++ kind: row.kind,
++ pinned: row.pinned === true,
++ // A sealed note is one the person's Locker keys closed: the text here is the envelope, and
++ // only the person can open it (in the browser).
++ sealed: row.sealed === true,
++ origin: row.origin,
++ status: item.status,
++ conflict: item.conflict_with ? true : false,
++ created_at: row.created_at,
++ updated_at: item.updated_at || row.updated_at,
++ });
++ }
++ return out;
++ }
++
++ /**
++ * Write a note: the text goes to M2, the index row follows it.
++ *
++ * `protect` is how a locked character's note is written: the caller seals the text with the
++ * person's Locker keys before it reaches M2, and the envelope is what M2 stores. The index says
++ * sealed, and only the person's browser opens it.
++ */
++ async function add(owner, characterId, { kind, text, origin = 'person', pinned = false, sealed = false }, token, protect = null) {
++ const k = KINDS.includes(kind) ? kind : 'fact';
++ const body = String(text == null ? '' : text).trim().slice(0, MAX_TEXT);
++ if (!body) {
++ const err = new Error('a note needs some text');
++ err.code = 'note.empty';
++ err.status = 422;
++ throw err;
++ }
++ // The hard floor holds on notes like on everything else.
++ const check = floor.check(body);
++ if (!check.ok) {
++ const err = new Error('that note is refused: ' + check.detail + ' (' + check.label + ')');
++ err.code = 'note.floor';
++ err.status = 422;
++ err.rule = check.rule;
++ throw err;
++ }
++ const scope = scopeFor(characterId);
++ const stored = protect ? await protect(body) : body;
++ const written = await memory.write(token, [{
++ text: stored,
++ scope,
++ kind: M2_KINDS[k],
++ source: 'companions',
++ status: 'active',
++ source_ref: { path: 'companions/' + String(characterId).toLowerCase(), heading: k },
++ }]);
++ const item = written && written.items && written.items[0];
++ if (!item) {
++ const err = new Error('the note was not written');
++ err.code = 'note.not_written';
++ err.status = 502;
++ throw err;
++ }
++ const usesLocker = Boolean(protect) || sealed === true;
++ const id = 'exn_' + require('openvibe-contracts').ids.ulid();
++ await db.prepare('INSERT INTO exchange_companion_notes (id, owner, character_id, m2_id, kind, pinned, sealed, origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, owner, String(characterId), item.id, k, pinned === true, usesLocker, origin === 'model' ? 'model' : 'person');
++ return { id, m2_id: item.id, kind: k, text: body, pinned: pinned === true, sealed: usesLocker, origin };
++ }
++
++ /** Edit a note's text: the M2 item is changed, the index keeps its say. */
++ async function edit(owner, noteId, { text }, token) {
++ const row = await db.prepare('SELECT * FROM exchange_companion_notes WHERE id = ? AND owner = ?').get(String(noteId), owner);
++ if (!row) {
++ const err = new Error('no such note');
++ err.code = 'note.not_found';
++ err.status = 404;
++ throw err;
++ }
++ if (row.sealed === true) {
++ // A sealed note was written with the person's keys: the server has no text to edit, and
++ // storing a plaintext edit here would be a note the person thinks is sealed but is not.
++ const err = new Error('this note is sealed with your Locker; it cannot be edited on the server');
++ err.code = 'note.sealed';
++ err.status = 409;
++ throw err;
++ }
++ const body = String(text == null ? '' : text).trim().slice(0, MAX_TEXT);
++ if (!body) {
++ const err = new Error('a note needs some text');
++ err.code = 'note.empty';
++ err.status = 422;
++ throw err;
++ }
++ const check = floor.check(body);
++ if (!check.ok) {
++ const err = new Error('that note is refused: ' + check.detail + ' (' + check.label + ')');
++ err.code = 'note.floor';
++ err.status = 422;
++ err.rule = check.rule;
++ throw err;
++ }
++ await memory.patch(token, row.m2_id, { text: body });
++ await db.prepare('UPDATE exchange_companion_notes SET updated_at = now() WHERE id = ? AND owner = ?').run(row.id, owner);
++ return { id: row.id, m2_id: row.m2_id, text: body };
++ }
++
++ /** Pin a note, or unpin it: the pin is ours, so it is a row of ours. */
++ async function pin(owner, noteId, pinned) {
++ const row = await db.prepare('SELECT * FROM exchange_companion_notes WHERE id = ? AND owner = ?').get(String(noteId), owner);
++ if (!row) {
++ const err = new Error('no such note');
++ err.code = 'note.not_found';
++ err.status = 404;
++ throw err;
++ }
++ await db.prepare('UPDATE exchange_companion_notes SET pinned = ?, updated_at = now() WHERE id = ? AND owner = ?')
++ .run(pinned === true, row.id, owner);
++ return { id: row.id, pinned: pinned === true };
++ }
++
++ /** Delete a note: the text goes from M2, the index row goes with it. */
++ async function remove(owner, noteId, token) {
++ const row = await db.prepare('SELECT * FROM exchange_companion_notes WHERE id = ? AND owner = ?').get(String(noteId), owner);
++ if (!row) {
++ const err = new Error('no such note');
++ err.code = 'note.not_found';
++ err.status = 404;
++ throw err;
++ }
++ await memory.remove(token, row.m2_id);
++ await db.prepare('DELETE FROM exchange_companion_notes WHERE id = ? AND owner = ?').run(row.id, owner);
++ return { id: row.id };
++ }
++
++ /**
++ * The notes that join a chat's context, within a token budget: the pinned ones first, then the
++ * newest. The budget is a budget, so a note that does not fit is left out rather than cut in half.
++ */
++ async function forContext(owner, characterId, token, { budgetTokens = 800, limit = 40 } = {}) {
++ let notes = [];
++ try {
++ notes = await list(owner, characterId, token);
++ } catch (err) {
++ log.warn('[Companions] the notes could not be read:', String((err && err.message) || err).slice(0, 200));
++ return { notes: [], budgetTokens, usedTokens: 0 };
++ }
++ const ordered = [...notes].sort((a, b) => (Number(b.pinned) - Number(a.pinned))
++ || (String(b.created_at || '').localeCompare(String(a.created_at || ''))));
++ const used = [];
++ let usedTokens = 0;
++ for (const note of ordered) {
++ if (used.length >= limit) break;
++ if (note.status === 'archived' || note.conflict) continue; // a conflict never reaches a model
++ if (note.sealed) continue; // a sealed note is opened in the browser and sent with the request
++ const cost = tokensOf(note.kind + ': ' + note.text);
++ if (usedTokens + cost > budgetTokens) continue;
++ used.push(note);
++ usedTokens += cost;
++ }
++ return { notes: used, budgetTokens, usedTokens };
++ }
++
++ return { list, add, edit, pin, remove, forContext, scopeFor, indexRows, KINDS };
++}
++
++module.exports = { createMemoryClient, createCompanionNotes, scopeFor, tokensOf, KINDS, M2_KINDS };
ddiff --git a/server/companions/taking-notes.js b/server/companions/taking-notes.js
nnew file mode 100644
iindex 0000000..21931db
---- /dev/null
++++ b/server/companions/taking-notes.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * Taking notes (Opus's design, 2026-11-06, stage 3): after every few turns the chat writes short memory
++ * notes with a cheap model.
++ *
++ * The cheap model: the router's economy alias (openvibe/economy - the alias whose policy is "cheapest"), under
++ * the person's own allowance and the free-use price ceiling every companion answer stays under
++ * (config.companions.maxCostUsd). The notes are short, and they are about the person: a fact, a preference or
++ * an event. They are written into the person's own memory (the M2 store), through the notes store, so the hard
++ * floor is checked on them like on everything else.
++ *
++ * The model is asked for a small JSON array and nothing else. Anything it says that is not a note is dropped: a
++ * note that cannot be read is not written.
++ */
++
++const floor = require('./floor');
++
++const MAX_NOTES = 6;
++const MAX_TEXT = 300;
++
++const SYSTEM = [
++ 'You keep short notes about the person you are talking with, for a companion app.',
++ 'Write down only what is worth remembering: facts about them (who they are, what they do, what they like),',
++ 'their preferences (how they want to be spoken to, what they like to talk about), and events that happened',
++ 'in the conversation (what they said they did, what they asked for).',
++ 'Never write down anything about the character, anything about the rules of the app, or anything sexual.',
++ 'Answer with a JSON array only, no text around it, like [{"kind":"fact","text":"..."}].',
++ 'kind is one of: fact, preference, event. If there is nothing worth remembering, answer [].',
++].join(' ');
++
++/** The notes in a model's answer: the array it promised, or nothing. */
++function parseNotes(text) {
++ const raw = String(text == null ? '' : text).trim();
++ if (!raw) return [];
++ let body = raw;
++ const start = body.indexOf('[');
++ const end = body.lastIndexOf(']');
++ if (start >= 0 && end > start) body = body.slice(start, end + 1);
++ let parsed = null;
++ try { parsed = JSON.parse(body); } catch (e) { parsed = null; }
++ if (!Array.isArray(parsed)) return [];
++ const out = [];
++ for (const item of parsed) {
++ if (!item || typeof item !== 'object') continue;
++ const text2 = String(item.text == null ? '' : item.text).trim().slice(0, MAX_TEXT);
++ if (!text2) continue;
++ const kind = ['fact', 'preference', 'event'].includes(String(item.kind)) ? String(item.kind) : 'fact';
++ // The hard floor holds on a note, whoever wrote it.
++ if (!floor.check(text2).ok) continue;
++ out.push({ kind, text: text2 });
++ if (out.length >= MAX_NOTES) break;
++ }
++ return out;
++}
++
++/** The transcript the cheap model reads: the last few turns, plainly. */
++function transcriptOf(turns, max = 2400) {
++ const parts = [];
++ for (const t of turns || []) {
++ if (t.user && t.user.content) parts.push('The person: ' + String(t.user.content).slice(0, 600));
++ if (t.answer && t.answer.content) parts.push('The companion: ' + String(t.answer.content).slice(0, 600));
++ }
++ const text = parts.join('\n');
++ return text.length > max ? text.slice(text.length - max) : text;
++}
++
++function createNoteTaker({ ai, notes, config, log = console } = {}) {
++ const every = Math.max(1, Number((config && config.companions && config.companions.notesEveryTurns) || 4));
++ const model = String((config && config.companions && config.companions.noteModel) || 'openvibe/economy');
++ const maxCost = (config && config.companions && config.companions.maxCostUsd) || 0.05;
++
++ /**
++ * Whether it is time to take notes: every few turns, and never more than once per turn.
++ */
++ function due(turnCount) {
++ return Number(turnCount) > 0 && Number(turnCount) % every === 0;
++ }
++
++ /**
++ * Take the notes of a chat. The notes are the model's own suggestions, written as suggestions: the person
++ * reads them in the Memory panel and edits, pins or deletes them. `protect` seals each note before it reaches
++ * the memory store, for a character locked with the person's Locker keys.
++ */
++ async function take({ owner, characterId, token, turns, protect = null }) {
++ const transcript = transcriptOf(turns);
++ if (!transcript.trim()) return { notes: [] };
++ let out = null;
++ try {
++ out = await ai.chat({
++ model,
++ messages: [
++ { role: 'system', content: SYSTEM },
++ { role: 'user', content: transcript },
++ ],
++ subject: owner,
++ policy: 'standard',
++ max_cost_usd: maxCost,
++ maxTokens: 600,
++ });
++ } catch (err) {
++ log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
++ return { notes: [] };
++ }
++ const parsed = parseNotes(out && out.content);
++ const written = [];
++ for (const note of parsed) {
++ try {
++ written.push(await notes.add(owner, characterId, { kind: note.kind, text: note.text, origin: 'model' }, token, protect));
++ } catch (err) {
++ log.warn('[Companions] a note was refused:', String((err && err.message) || err).slice(0, 160));
++ }
++ }
++ return { notes: written };
++ }
++
++ return { take, due, parseNotes, transcriptOf, every, model };
++}
++
++module.exports = { createNoteTaker, parseNotes, transcriptOf, MAX_NOTES };
ddiff --git a/server/config.js b/server/config.js
iindex d3319b2..75ee6be 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -84,6 +84,13 @@ function load(env = process.env) {
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),
++ // The memory notes (stage 3): how many tokens of notes may join a chat's context, how often the
++ // cheap model takes notes, and which model takes them (the router's economy alias).
++ memoryBudgetTokens: Math.max(100, int(env.EXCHANGE_COMPANIONS_MEMORY_BUDGET_TOKENS, 800)),
++ notesEveryTurns: Math.max(1, int(env.EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS, 4)),
++ noteModel: String(env.EXCHANGE_COMPANIONS_NOTE_MODEL || 'openvibe/economy').slice(0, 120),
++ // The lorebook entries (stage 4): how many tokens of entries may join a chat's context.
++ lorebookBudgetTokens: Math.max(100, int(env.EXCHANGE_COMPANIONS_LOREBOOK_BUDGET_TOKENS, 1200)),
},
// /chat: how many messages a signed-in person may send a day (each one may run up to three models).
ddiff --git a/server/http/companions.js b/server/http/companions.js
iindex 7a11da2..54a0767 100644
---- a/server/http/companions.js
++++ b/server/http/companions.js
@@@ -28,6 +28,7 @@ const { send } = require('../render/layout');
const views = require('../views/companions');
const floor = require('../companions/floor');
const codec = require('../companions/codec');
++const { tokensOf, KINDS: NOTE_KINDS } = require('../companions/memory');
const { readTurns } = require('../companions/store');
const multer = require('multer');
@@@ -35,7 +36,8 @@ const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-
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 { config, ai, adult, adultAccount, characters, companionChats, sharing, memory, notes, lorebooks, locker, noteTaker, log } = ctx;
++ const db = ctx.s && ctx.s.db;
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 } });
@@@ -77,6 +79,114 @@ function createCompanionRoutes(ctx) {
return await adult.confirmed(owner(req));
};
++ const tokenOf = (req) => req.token || (req.viewer && req.viewer.token) || null;
++
++ /** The person's Locker public keys, or null when they have no Locker (or it cannot be read). */
++ async function lockerKeys(req) {
++ try {
++ return await locker.keysFor(owner(req), tokenOf(req));
++ } catch (err) {
++ log.warn('[Companions] the Locker keys could not be read:', String((err && err.message) || err).slice(0, 200));
++ return null;
++ }
++ }
++
++ /** The character's lock row: when it is there, the notes and the chats of the character are sealed. */
++ async function lockRow(characterId, subject) {
++ if (!subject) return null;
++ return await db.prepare('SELECT * FROM exchange_companion_locks WHERE owner = ? AND character_id = ?')
++ .get(subject, String(characterId)) || null;
++ }
++
++ /** Seal a text with the person's Locker keys, for a locked character. */
++ function protectWith(keys) {
++ if (!keys) return null;
++ return (text) => locker.seal(String(text == null ? '' : text), keys);
++ }
++
++ /**
++ * The bundle a locked chat's browser sends with a turn: the turns it opened, the texts of the messages it may
++ * regenerate, and the notes it opened. The server has no other way to see them: at rest they are envelopes.
++ */
++ function parseLockerContext(req) {
++ const raw = req.body && req.body.locker_context;
++ if (!raw) return null;
++ let body = null;
++ try { body = JSON.parse(String(raw)); } catch (e) { return null; }
++ if (!body || typeof body !== 'object') return null;
++ const history = (Array.isArray(body.history) ? body.history : [])
++ .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
++ .slice(-200)
++ .map((m) => ({ role: m.role, content: m.content.slice(0, 8000) }));
++ const messages = {};
++ if (body.messages && typeof body.messages === 'object' && !Array.isArray(body.messages)) {
++ for (const [id, text] of Object.entries(body.messages)) {
++ if (typeof text === 'string') messages[String(id)] = text.slice(0, 8000);
++ }
++ }
++ const noteList = (Array.isArray(body.notes) ? body.notes : [])
++ .filter((n) => n && typeof n.text === 'string' && n.text.trim())
++ .map((n) => ({
++ id: String(n.id || '').slice(0, 80),
++ m2_id: String(n.m2_id || '').slice(0, 80),
++ kind: ['fact', 'preference', 'event'].includes(String(n.kind)) ? String(n.kind) : 'fact',
++ text: String(n.text).slice(0, 2000),
++ pinned: n.pinned === true,
++ }));
++ return { history, messages, notes: noteList };
++ }
++
++ /** The turns the model sees in a locked chat, within the context size, newest last. */
++ function lockedHistory(bundle, contextSize) {
++ const limit = Math.max(2000, Number(contextSize) || 0);
++ const out = [];
++ let chars = 0;
++ const list = (bundle && bundle.history) || [];
++ for (let i = list.length - 1; i >= 0; i--) {
++ const m = list[i];
++ if (out.length && chars + m.content.length > limit) break;
++ chars += m.content.length;
++ out.unshift({ role: m.role, content: m.content });
++ }
++ return out;
++ }
++
++ /** The decrypted notes a locked chat's browser sent, pinned first, within the note budget. */
++ function lockedNotes(bundle, budgetTokens) {
++ const ordered = [...((bundle && bundle.notes) || [])].sort((a, b) => Number(b.pinned) - Number(a.pinned));
++ const used = [];
++ let usedTokens = 0;
++ for (const note of ordered) {
++ const cost = tokensOf(note.kind + ': ' + note.text);
++ if (usedTokens + cost > budgetTokens) continue;
++ used.push(note);
++ usedTokens += cost;
++ }
++ return { notes: used, usedTokens, budgetTokens };
++ }
++
++ /** The notes that join a context, read from M2 in a plain (unlocked) chat. */
++ async function contextNotes(req, card) {
++ const budgetTokens = config.companions.memoryBudgetTokens;
++ try {
++ return await notes.forContext(owner(req), card.id, tokenOf(req), { budgetTokens });
++ } catch (err) {
++ log.warn('[Companions] the notes could not be read:', String((err && err.message) || err).slice(0, 200));
++ return { notes: [], usedTokens: 0, budgetTokens };
++ }
++ }
++
++ /** The lorebook entries that join a turn, highest priority first, within the lorebook budget. */
++ async function contextEntries(card, chat, subject, recentText) {
++ try {
++ const books = await lorebooks.booksFor(subject, { characterId: card.id, chatId: chat.id });
++ return lorebooks.forContext(books, recentText, { budgetTokens: config.companions.lorebookBudgetTokens });
++ } catch (err) {
++ log.warn('[Companions] the lorebooks could not be read:', String((err && err.message) || err).slice(0, 200));
++ return { entries: [], usedTokens: 0, budgetTokens: config.companions.lorebookBudgetTokens };
++ }
++ }
++
/** The card's fields, out of a form body or an imported card. */
function cardOf(body, imported = null) {
const b = body || {};
@@@ -275,17 +385,303 @@ function createCompanionRoutes(ctx) {
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 } });
++ // A character whose Locker is on gets every new chat locked from its first message.
++ const lock = await lockRow(card.id, owner(req)).catch(() => null);
++ if (lock) {
++ await db.prepare('UPDATE exchange_companion_chats SET locked = true, locker_fingerprint = ? WHERE id = ? AND owner = ?')
++ .run(lock.fingerprint || null, chat.id, owner(req));
++ }
return res.redirect(303, '/talk/' + chat.id);
});
++ // ── Memory and lorebooks: the panels of a character ─────────────────────
++ /** The card a page is about, with the same sharing and 18+ rules as its own page. */
++ async function cardFor(req, res) {
++ const card = await characters.bySlug(req.params.handle, req.params.character);
++ if (!card) {
++ 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);
++ return null;
++ }
++ const adultOk = await isAdult(req);
++ if (card.rating === 'adult' && !adultOk) {
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ page(req, res, { title: '18+', index: false, body: raw(views.adultGate()) }, 403);
++ return null;
++ }
++ 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');
++ 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);
++ return null;
++ }
++ return card;
++ }
++
++ async function memoryView(req, res, card, { problem = null, notice = null, status = 200 } = {}) {
++ const lock = await lockRow(card.id, owner(req)).catch(() => null);
++ let list = [];
++ let readProblem = problem;
++ try {
++ list = await notes.list(owner(req), card.id, tokenOf(req));
++ } catch (err) {
++ readProblem = readProblem || ('Your memory could not be read: ' + String((err && err.message) || err).slice(0, 200));
++ }
++ return page(req, res, {
++ title: 'Memory: ' + card.name, index: false,
++ head: lock ? '<link rel="stylesheet" href="' + require('openvibe-shared/serve').url('ov-locker.css') + '"><script src="' + require('openvibe-shared/serve').url('ov-locker.js') + '" defer></script><script src="/js/companions-locker.js" defer></script>' : '',
++ body: raw(views.memoryPage({ card, notes: list, locked: Boolean(lock), lock, problem: readProblem, notice })),
++ }, status);
++ }
++
++ r.get('/c/:handle/:character/memory', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to see your memory', text: 'The notes live in your account, not in the character.', next: req.originalUrl });
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ return memoryView(req, res, card);
++ });
++
++ r.post('/c/:handle/:character/memory/add', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to keep a note', text: 'The notes live in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ const lock = await lockRow(card.id, owner(req)).catch(() => null);
++ const keys = lock ? await lockerKeys(req) : null;
++ if (lock && !keys) return memoryView(req, res, card, { problem: 'This character is sealed with your Locker, and your keys could not be read. Nothing was written.', status: 409 });
++ try {
++ await notes.add(owner(req), card.id, {
++ kind: (req.body && req.body.kind) || 'fact',
++ text: (req.body && req.body.text) || '',
++ origin: 'person',
++ }, tokenOf(req), protectWith(keys));
++ return memoryView(req, res, card, { notice: 'The note was saved.' });
++ } catch (err) {
++ return memoryView(req, res, card, { problem: String((err && err.message) || err).slice(0, 300), status: 422 });
++ }
++ });
++
++ async function oneNote(req, res, card, act) {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'The notes live in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ try {
++ await act();
++ return memoryView(req, res, card, { notice: 'Saved.' });
++ } catch (err) {
++ return memoryView(req, res, card, { problem: String((err && err.message) || err).slice(0, 300), status: Number(err && err.status) || 422 });
++ }
++ }
++
++ r.post('/c/:handle/:character/memory/:note/edit', async (req, res) => {
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ return oneNote(req, res, card, () => notes.edit(owner(req), req.params.note, { text: (req.body && req.body.text) || '' }, tokenOf(req)));
++ });
++
++ r.post('/c/:handle/:character/memory/:note/pin', async (req, res) => {
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ return oneNote(req, res, card, () => notes.pin(owner(req), req.params.note, String((req.body && req.body.pinned) || '') === '1'));
++ });
++
++ r.post('/c/:handle/:character/memory/:note/delete', async (req, res) => {
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ return oneNote(req, res, card, () => notes.remove(owner(req), req.params.note, tokenOf(req)));
++ });
++
++ /**
++ * Turn the Locker on or off for a character. On: every note and every message of every chat with
++ * the character is sealed with the person's keys, then the chats are marked locked. Off: only when
++ * nothing is sealed, because the server cannot open what it never had the key for.
++ */
++ r.post('/c/:handle/:character/lock', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'The Locker is yours.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ const on = String((req.body && req.body.on) || '') === '1';
++ const subject = owner(req);
++ const token = tokenOf(req);
++ if (!on) {
++ const sealedNotes = Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_notes WHERE owner = $1 AND character_id = $2 AND sealed = true', [subject, card.id])) || 0;
++ const sealedMessages = Number(await db.value("SELECT COUNT(*)::int AS n FROM exchange_companion_messages m JOIN exchange_companion_chats c ON c.id = m.chat_id WHERE c.owner = $1 AND c.character_id = $2 AND m.meta LIKE '%\"sealed\":true%'", [subject, card.id])) || 0;
++ if (sealedNotes || sealedMessages) {
++ return memoryView(req, res, card, { problem: 'This character stays locked: ' + (sealedNotes + sealedMessages) + ' notes and messages are sealed, and only your keys open them. OpenVibe cannot turn them back into text.', status: 409 });
++ }
++ await db.prepare('DELETE FROM exchange_companion_locks WHERE owner = ? AND character_id = ?').run(subject, card.id);
++ await db.prepare('UPDATE exchange_companion_chats SET locked = false, locker_fingerprint = NULL WHERE owner = ? AND character_id = ?').run(subject, card.id);
++ return memoryView(req, res, card, { notice: 'The Locker is off for this character.' });
++ }
++ const keys = await lockerKeys(req);
++ if (!keys) return memoryView(req, res, card, { problem: 'You have no Locker keys yet. Set up your Locker first, then turn it on here.', status: 409 });
++ try {
++ // The notes: everything not sealed yet is sealed now.
++ const list = await notes.list(subject, card.id, token);
++ for (const note of list) {
++ if (note.sealed) continue;
++ const sealed = await locker.seal(note.text, keys);
++ await memory.patch(token, note.m2_id, { text: sealed });
++ await db.prepare('UPDATE exchange_companion_notes SET sealed = true, updated_at = now() WHERE id = ? AND owner = ?').run(note.id, subject);
++ }
++ // The chats: every message in the character's chats, and the chats are marked locked.
++ const chats = await db.prepare('SELECT id FROM exchange_companion_chats WHERE owner = ? AND character_id = ?').all(subject, card.id);
++ for (const chat of chats) {
++ const messages = await companionChats.messages(chat.id);
++ for (const message of messages) {
++ if (message.content == null || (message.meta && message.meta.sealed)) continue;
++ const sealed = await locker.seal(message.content, keys);
++ await db.prepare('UPDATE exchange_companion_messages SET content = ?, meta = ? WHERE id = ? AND owner = ?')
++ .run(sealed, JSON.stringify({ ...(message.meta || {}), sealed: true }), message.id, subject);
++ }
++ }
++ await db.prepare('INSERT INTO exchange_companion_locks (owner, character_id, fingerprint) VALUES (?, ?, ?) ON CONFLICT (owner, character_id) DO UPDATE SET fingerprint = excluded.fingerprint, updated_at = now()')
++ .run(subject, card.id, keys.fingerprint || null);
++ await db.prepare('UPDATE exchange_companion_chats SET locked = true, locker_fingerprint = ? WHERE owner = ? AND character_id = ?')
++ .run(keys.fingerprint || null, subject, card.id);
++ return memoryView(req, res, card, { notice: 'The Locker is on. The notes and the chats of this character are sealed with your keys.' });
++ } catch (err) {
++ log.warn('[Companions] the character could not be locked:', String((err && err.message) || err).slice(0, 200));
++ return memoryView(req, res, card, { problem: 'The Locker could not be turned on: ' + String((err && err.message) || err).slice(0, 200), status: 409 });
++ }
++ });
++
++ async function lorebookView(req, res, card, { problem = null, notice = null, status = 200 } = {}) {
++ const chatId = String((req.query && req.query.chat) || (req.body && req.body.chat_id) || '');
++ const chat = chatId ? await companionChats.get(owner(req), chatId).catch(() => null) : null;
++ const scopedChat = chat && chat.character_id === card.id ? chat : null;
++ const books = await lorebooks.booksFor(owner(req), { characterId: card.id, chatId: scopedChat ? scopedChat.id : null }).catch(() => []);
++ return page(req, res, {
++ title: 'Lorebooks: ' + card.name, index: false,
++ body: raw(views.lorebookPage({ card, books, chat: scopedChat, problem, notice })),
++ }, status);
++ }
++
++ /** The book, if it is the person's and belongs to this character (or their world). */
++ async function bookFor(ownerSubject, card, bookId, chatId = '') {
++ const book = await lorebooks.book(ownerSubject, bookId).catch(() => null);
++ if (!book) return null;
++ if (book.scope === 'person') return book;
++ if (book.scope === 'character' && book.character_id === card.id) return book;
++ if (book.scope === 'chat' && chatId && book.chat_id === chatId) return book;
++ return null;
++ }
++
++ r.get('/c/:handle/:character/lorebook', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to keep a lorebook', text: 'Lorebooks live in your account.', next: req.originalUrl });
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ return lorebookView(req, res, card);
++ });
++
++ r.post('/c/:handle/:character/lorebook', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'Lorebooks live in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ let scope = ['person', 'character', 'chat'].includes(String((req.body && req.body.scope) || '')) ? String(req.body.scope) : 'person';
++ let chatId = String((req.body && req.body.chat_id) || '') || null;
++ if (scope === 'chat') {
++ const chat = chatId ? await companionChats.get(owner(req), chatId).catch(() => null) : null;
++ if (!chat || chat.character_id !== card.id) return lorebookView(req, res, card, { problem: 'A chat lorebook needs one of your chats with this character.', status: 422 });
++ } else {
++ chatId = null;
++ }
++ try {
++ await lorebooks.create(owner(req), { name: (req.body && req.body.name) || 'A lorebook', scope, characterId: card.id, chatId });
++ const query = scope === 'chat' ? '?chat=' + encodeURIComponent(chatId) : '';
++ return res.redirect(303, '/c/' + encodeURIComponent(card.handle) + '/' + encodeURIComponent(card.slug) + '/lorebook' + query);
++ } catch (err) {
++ return lorebookView(req, res, card, { problem: String((err && err.message) || err).slice(0, 300), status: 422 });
++ }
++ });
++
++ async function onBook(req, res, act) {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'Lorebooks live in your account.', next: req.originalUrl });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const card = await cardFor(req, res);
++ if (!card) return undefined;
++ const chatId = String((req.query && req.query.chat) || (req.body && req.body.chat_id) || '');
++ const book = await bookFor(owner(req), card, req.params.book, chatId);
++ if (!book) return lorebookView(req, res, card, { problem: 'No such lorebook.', status: 404 });
++ try {
++ const out = await act(book);
++ if (out && out.redirect) return res.redirect(303, out.redirect);
++ return lorebookView(req, res, card, { notice: out && out.notice ? out.notice : 'Saved.' });
++ } catch (err) {
++ return lorebookView(req, res, card, { problem: String((err && err.message) || err).slice(0, 300), status: Number(err && err.status) || 422 });
++ }
++ }
++
++ r.post('/c/:handle/:character/lorebook/:book/entries', async (req, res) => onBook(req, res, async (book) => {
++ await lorebooks.addEntry(owner(req), book.id, {
++ name: (req.body && req.body.name) || '',
++ keys: (req.body && req.body.keys) || '',
++ content: (req.body && req.body.content) || '',
++ priority: (req.body && req.body.priority) || 0,
++ constant: Boolean(req.body && req.body.constant),
++ });
++ return { notice: 'The entry was added.' };
++ }));
++
++ r.post('/c/:handle/:character/lorebook/:book/entry/:entry', async (req, res) => onBook(req, res, async () => {
++ await lorebooks.editEntry(owner(req), req.params.entry, {
++ name: (req.body && req.body.name) || '',
++ keys: (req.body && req.body.keys) || '',
++ content: (req.body && req.body.content) || '',
++ priority: (req.body && req.body.priority) || 0,
++ constant: Boolean(req.body && req.body.constant),
++ });
++ return { notice: 'The entry was saved.' };
++ }));
++
++ r.post('/c/:handle/:character/lorebook/:book/entry/:entry/delete', async (req, res) => onBook(req, res, async () => {
++ await lorebooks.removeEntry(owner(req), req.params.entry);
++ return { notice: 'The entry was deleted.' };
++ }));
++
++ r.post('/c/:handle/:character/lorebook/:book/delete', async (req, res) => onBook(req, res, async (book) => {
++ await lorebooks.removeBook(owner(req), book.id);
++ return { notice: 'The lorebook was deleted.' };
++ }));
++
++ r.post('/c/:handle/:character/lorebook/:book/import', upload.single('file'), async (req, res) => onBook(req, res, async (book) => {
++ const text = req.file && req.file.buffer && req.file.buffer.length ? req.file.buffer.toString('utf8') : String((req.body && req.body.world_info) || '');
++ const made = await lorebooks.importWorldInfo(owner(req), book.id, text);
++ return { notice: made.length + ' entries were imported.' };
++ }));
++
++ r.get('/c/:handle/:character/lorebook/:book/export', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in first', text: 'Lorebooks live in your account.', next: req.originalUrl });
++ const card = await characters.bySlug(req.params.handle, req.params.character);
++ if (!card) return res.redirect(303, '/companions');
++ const book = await bookFor(owner(req), card, req.params.book, String((req.query && req.query.chat) || ''));
++ if (!book) return res.redirect(303, '/c/' + encodeURIComponent(card.handle) + '/' + encodeURIComponent(card.slug) + '/lorebook');
++ const entries = await lorebooks.entriesOf(owner(req), book.id);
++ const out = lorebooks.toWorldInfo([{ ...book, entries }]);
++ res.set('Content-Type', 'application/json; charset=utf-8');
++ res.set('Content-Disposition', 'attachment; filename="' + String(book.name || 'lorebook').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) + '.worldinfo.json"');
++ return res.send(JSON.stringify(out, null, 2));
++ });
++
// ── Chats: one character, a branching tree, the folded receipt ────────
-- function systemPromptFor(card, chat) {
++ function systemPromptFor(card, chat, { notes: notesIn = [], entries = [] } = {}) {
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);
++ if (notesIn.length) {
++ // The person's own memory, within its budget, pinned notes first: the character knows it.
++ lines.push('What you remember about the person (their own notes, which they can read and edit):');
++ for (const note of notesIn) lines.push('- (' + note.kind + ') ' + note.text);
++ }
++ if (entries.length) {
++ // The lorebook, within its budget, highest priority first.
++ lines.push('World info for this conversation (use it when it fits the scene):');
++ for (const entry of entries) lines.push('- ' + entry.content);
++ }
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 (fiction included) or incest, no help with weapons of mass harm or attacks on others.');
return lines.join(String.fromCharCode(10));
}
@@@ -380,6 +776,8 @@ function createCompanionRoutes(ctx) {
/** 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) {
++ // A sealed message has no text here to edit: its words are in the person's browser, not on the server.
++ if (turn.user.meta && turn.user.meta.sealed) return '';
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>'
@@@ -399,12 +797,19 @@ function createCompanionRoutes(ctx) {
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>'
++ + ((turn.user.meta && turn.user.meta.sealed)
++ ? '<p class="ovt-bubble">' + views.sealedText(turn.user.content, { role: 'user', key: 'm:' + turn.user.id, message: turn.user.id }) + '</p>'
++ : '<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 })
++ + thread.answer({
++ html: (turn.answer.meta && turn.answer.meta.sealed)
++ ? views.sealedText(turn.answer.content, { role: 'assistant', key: 'm:' + turn.answer.id, message: turn.answer.id })
++ : esc(turn.answer.content || '').split(String.fromCharCode(10)).join('<br>'),
++ meta: [], copy: false,
++ })
+ receiptFor(turn.answer)
+ '</article>'
: '';
@@@ -421,26 +826,47 @@ function createCompanionRoutes(ctx) {
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. */
++ /** What the model saw: the card, the settings, the notes, the lorebook, 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 locked = Boolean(chat.locked || (saw && saw.locked));
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 });
++ if (chat.locked) rows.push({ title: 'Locker', sub: 'on: the history and the notes are sealed at rest' + (chat.locker_fingerprint ? ' (' + chat.locker_fingerprint + ')' : '') });
++ const notesIn = saw && Array.isArray(saw.notes) ? saw.notes : [];
++ const entriesIn = saw && Array.isArray(saw.lorebook) ? saw.lorebook : [];
++ const notesHtml = notesIn.length
++ ? '<ul class="ex-saw-list">' + notesIn.map((n) => '<li><b>' + esc(n.kind) + '</b>' + (n.pinned ? ' <span class="ex-chip is-on">pinned</span>' : '') + ' ' + ((n.text && !locked) ? esc(String(n.text).slice(0, 300)) : '<span class="ex-muted">sealed</span>') + ' <span class="ex-muted">(' + Number(n.tokens || 0) + ' tokens)</span></li>').join('') + '</ul>'
++ + '<p class="ex-muted">' + Number(saw.notes_used_tokens || 0) + ' of ' + Number(saw.notes_budget_tokens || 0) + ' tokens of notes joined this answer.</p>'
++ : '<p class="ex-muted">No notes joined this answer.</p>';
++ const entriesHtml = entriesIn.length
++ ? '<ul class="ex-saw-list">' + entriesIn.map((e) => '<li><b>' + esc(e.name || 'An entry') + '</b>'
++ + (e.keys && e.keys.length ? ' <span class="ex-muted">(' + esc(e.keys.join(', ')) + ')</span>' : '')
++ + ((e.text && !locked) ? ': ' + esc(String(e.text).slice(0, 300)) : ' <span class="ex-muted">sealed</span>')
++ + ' <span class="ex-muted">priority ' + Number(e.priority || 0) + ' · ' + Number(e.tokens || 0) + ' tokens</span></li>').join('') + '</ul>'
++ + '<p class="ex-muted">' + Number(saw.lorebook_used_tokens || 0) + ' of ' + Number(saw.lorebook_budget_tokens || 0) + ' tokens of entries joined this answer.</p>'
++ : '<p class="ex-muted">No lorebook entries joined this answer.</p>';
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>';
++ const contextHtml = locked
++ ? '<p class="ex-muted">This chat is sealed with your Locker: its history was opened in your browser and sent for this answer only. OpenVibe holds no readable copy.</p>'
++ : 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 = locked
++ ? '<p class="ex-muted">The system prompt was built from the card and the notes your browser sent. The sealed copy on the server never held it.</p>'
++ : 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: 'Your notes', html: notesHtml },
++ { title: 'Lorebook entries', html: entriesHtml },
{ title: 'The system prompt', html: systemHtml },
{ title: 'The context sent', html: contextHtml },
],
@@@ -452,11 +878,12 @@ function createCompanionRoutes(ctx) {
* 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) {
++ function chatSettings(chat, models, card) {
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');
++ const base = '/c/' + encodeURIComponent(card.handle) + '/' + encodeURIComponent(card.slug);
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>'
@@@ -473,6 +900,11 @@ function createCompanionRoutes(ctx) {
+ '<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 class="ex-chat-links">'
++ + '<a class="ex-chip" href="' + esc(base + '/memory') + '">Memory' + (chat.locked ? ' · locked' : '') + '</a>'
++ + '<a class="ex-chip" href="' + esc(base + '/lorebook?chat=' + encodeURIComponent(chat.id)) + '">Lorebook</a>'
++ + (chat.locked ? '<span class="ex-chip">🔒 with your Locker</span>' : '')
++ + '</div>'
+ '</div></details>';
}
@@@ -484,21 +916,26 @@ function createCompanionRoutes(ctx) {
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)
++ + chatSettings(chat, models, card)
+ '<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) {
++ const lockedHead = chat.locked
++ ? '<div id="ex-locker-note" data-locker-chat><p class="ex-muted">🔒 This chat is sealed with your Locker. Unlock it in this browser to read the older turns.</p></div>'
++ : '';
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 }) : '',
++ top: lockedHead + (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.',
++ note: chat.locked
++ ? 'Sealed with your Locker. Unlock it in this browser to read the older turns; what you send is sealed before it is stored, and the opened history is sent for this answer only.'
++ : 'Your own daily allowance. The hard floor holds on every model and every character.',
});
}
@@@ -523,17 +960,37 @@ function createCompanionRoutes(ctx) {
composer: chat.ended_at ? '' : composerFor(chat, card, problem),
panel: sawPanel(chat, card, turns),
});
++ const lockerScripts = chat.locked
++ ? '<link rel="stylesheet" href="' + require('openvibe-shared/serve').url('ov-locker.css') + '">'
++ + '<script src="' + require('openvibe-shared/serve').url('ov-locker.js') + '" defer></script>'
++ + '<script src="/js/companions-locker.js" defer></script>'
++ : '';
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>',
++ bodyClass: 'ex-app ex-companion-shell' + (chat.locked ? ' ex-chat-locked' : ''),
++ head: '<link rel="stylesheet" href="' + require('openvibe-shared/serve').url('thread.css') + '"><script src="' + require('openvibe-shared/serve').url('ov-thread.js') + '" defer></script>' + lockerScripts,
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, {
++ /** Turn a flat list of messages into the pairs the note taker reads. */
++ function pairsOf(flat) {
++ const out = [];
++ for (let i = 0; i < flat.length; i++) {
++ if (flat[i].role !== 'user') continue;
++ const answer = flat[i + 1] && flat[i + 1].role === 'assistant' ? flat[i + 1] : null;
++ out.push({ user: { content: flat[i].content }, answer: answer ? { content: answer.content } : null });
++ if (answer) i++;
++ }
++ return out;
++ }
++
++ async function answerTurn(req, res, chat, card, { userMessage, turnId, variant, plainText = null, lockerBundle = null, keys = null }) {
++ const subject = owner(req);
++ const token = tokenOf(req);
++ const locked = chat.locked === true;
++ const settings = await companionChats.settings(subject, 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,
@@@ -542,19 +999,45 @@ function createCompanionRoutes(ctx) {
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 userText = plainText != null ? String(plainText) : String(userMessage.content == null ? '' : userMessage.content);
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 }]);
++ let history;
++ let noteResult;
++ if (locked) {
++ if (!lockerBundle) return renderChat(req, res, chat, card, { problem: 'This chat is sealed with your Locker. Unlock it in this browser and send again: the older turns never reach the server in the clear.', status: 422 });
++ if (!keys) return renderChat(req, res, chat, card, { problem: 'Your Locker keys could not be read, so the answer could not be sealed. Nothing was saved.', status: 409 });
++ history = lockedHistory(lockerBundle, settings.context_size);
++ noteResult = lockedNotes(lockerBundle, config.companions.memoryBudgetTokens);
++ } else {
++ const context = contextTurns(turns, settings.context_size);
++ history = [];
++ for (const t of context) {
++ // 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;
++ history.push({ role: 'user', content: t.user.content });
++ if (t.answer && t.answer.content) history.push({ role: 'assistant', content: t.answer.content });
++ }
++ noteResult = await contextNotes(req, card);
++ }
++ const asked = history.concat([{ role: 'user', content: userText }]);
++ const recent = asked.slice(-8).map((m) => m.content).join(String.fromCharCode(10));
++ const entryResult = await contextEntries(card, chat, subject, recent);
++ const system = systemPromptFor(card, settings, { notes: noteResult.notes, entries: entryResult.entries });
++ const notesSaw = noteResult.notes.map((n) => ({
++ id: n.id || n.m2_id || null, kind: n.kind, pinned: n.pinned === true,
++ tokens: tokensOf(n.kind + ': ' + n.text), text: locked ? null : n.text,
++ }));
++ const entriesSaw = entryResult.entries.map((e) => ({
++ id: e.id, name: e.name || '', keys: e.keys || [], priority: Number(e.priority) || 0,
++ tokens: tokensOf(e.content), text: locked ? null : e.content,
++ }));
++ const sawBase = {
++ locked, temperature: settings.temperature,
++ notes: notesSaw, notes_used_tokens: noteResult.usedTokens, notes_budget_tokens: noteResult.budgetTokens,
++ lorebook: entriesSaw, lorebook_used_tokens: entryResult.usedTokens, lorebook_budget_tokens: entryResult.budgetTokens,
++ };
const adultOk = await isAdult(req);
const model = await modelIdOf(settings.model || card.model);
try {
@@@ -562,7 +1045,7 @@ function createCompanionRoutes(ctx) {
model,
messages: [{ role: 'system', content: system }].concat(asked),
temperature: settings.temperature,
-- subject: owner(req),
++ subject,
policy: card.rating === 'adult' && adultOk ? 'uncensored' : 'standard',
preset: settings.preset || null,
max_cost_usd: config.companions.maxCostUsd,
@@@ -572,26 +1055,41 @@ function createCompanionRoutes(ctx) {
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,
++ owner: subject, 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 } },
++ meta: { error: 'floor', rule: check.rule, label: check.label, review: true, sealed: locked, saw: { ...sawBase, model, system: locked ? null : system, context: locked ? [] : asked } },
});
-- await ctx.s.db.prepare('UPDATE exchange_companion_chats SET ended_at = now() WHERE id = ? AND owner = ?').run(chat.id, owner(req));
++ await db.prepare('UPDATE exchange_companion_chats SET ended_at = now() WHERE id = ? AND owner = ?').run(chat.id, subject);
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 });
}
++ const stored = locked ? await locker.seal(answer, keys) : answer;
await companionChats.append({
-- owner: owner(req), chatId: chat.id, role: 'assistant', content: answer,
++ owner: subject, chatId: chat.id, role: 'assistant', content: stored,
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 },
++ sealed: locked,
++ saw: { ...sawBase, model: out.model || model, system: locked ? null : system, context: locked ? [] : asked },
},
});
++ // After every few turns, the cheap model writes short notes into the person's own memory.
++ try {
++ const after = await companionChats.messages(chat.id);
++ const turnCount = new Set(after.filter((m) => m.role === 'user').map((m) => m.turn_id)).size;
++ if (noteTaker.due(turnCount)) {
++ const noteTurns = locked
++ ? pairsOf(history.concat([{ role: 'user', content: userText }, { role: 'assistant', content: answer }]))
++ : readTurns(after, req.query || {}).slice(-4);
++ await noteTaker.take({ owner: subject, characterId: card.id, token, turns: noteTurns, protect: locked ? protectWith(keys) : null });
++ }
++ } catch (err) {
++ log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
++ }
} catch (err) {
await companionChats.append({
-- owner: owner(req), chatId: chat.id, role: 'assistant', content: null,
++ owner: subject, 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 },
});
@@@ -624,16 +1122,25 @@ function createCompanionRoutes(ctx) {
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 locked = chat.locked === true;
++ let lockerBundle = null;
++ let keys = null;
++ if (locked) {
++ lockerBundle = parseLockerContext(req);
++ if (!lockerBundle) return renderChat(req, res, chat, card, { problem: 'This chat is sealed with your Locker. Unlock it in this browser and send again: the older turns never reach the server in the clear.', status: 422 });
++ keys = await lockerKeys(req);
++ if (!keys) return renderChat(req, res, chat, card, { problem: 'Your Locker keys could not be read, so nothing was saved. Try again in a moment.', status: 409 });
++ }
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: {},
++ owner: owner(req), chatId: chat.id, role: 'user',
++ content: locked ? await locker.seal(text, keys) : text,
++ turnId, variant: 0, parentId: null, meta: locked ? { sealed: true } : {},
});
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 });
++ return answerTurn(req, res, chat, card, { userMessage: user, turnId, variant: 0, plainText: text, lockerBundle, keys });
});
/** Edit a message: the edit becomes another branch of the same turn, and the tree keeps both. */
@@@ -644,6 +1151,7 @@ function createCompanionRoutes(ctx) {
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.locked) return renderChat(req, res, chat, card, { problem: 'This chat is sealed with your Locker: a sealed message is edited in your browser, not here. Regenerate or send a new message instead.', status: 403 });
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');
@@@ -660,7 +1168,7 @@ function createCompanionRoutes(ctx) {
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 });
++ return answerTurn(req, res, chat, card, { userMessage: user, turnId: original.turn_id, variant, plainText: text });
});
/** Regenerate: another answer to the same message, kept as a branch. */
@@@ -675,8 +1183,19 @@ function createCompanionRoutes(ctx) {
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 });
++ let plainText = null;
++ let lockerBundle = null;
++ let keys = null;
++ if (chat.locked) {
++ lockerBundle = parseLockerContext(req);
++ if (!lockerBundle) return renderChat(req, res, chat, card, { problem: 'This chat is sealed with your Locker. Unlock it in this browser and regenerate again.', status: 422 });
++ plainText = lockerBundle.messages[original.id] || null;
++ if (!plainText) return renderChat(req, res, chat, card, { problem: 'That message could not be opened in your browser, so it cannot be regenerated.', status: 422 });
++ keys = await lockerKeys(req);
++ if (!keys) return renderChat(req, res, chat, card, { problem: 'Your Locker keys could not be read, so the answer could not be sealed. Nothing was saved.', status: 409 });
++ }
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 });
++ return answerTurn(req, res, chat, card, { userMessage: original, turnId: original.turn_id, variant, plainText, lockerBundle, keys });
});
r.post('/talk/:id/delete', async (req, res) => {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 69e67fc..038ec26 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -28,6 +28,13 @@ const TABLES = [
{ 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' },
++ // Memory notes and lorebooks (stages 3 and 4, 2026-11-06). The note TEXT lives in the person's own
++ // OpenVibe memory (the M2 store, OpenVibe.AI), which its own account export and deletion carry; Exchange
++ // exports and erases the index (kind, pin, who wrote it) and every lorebook entry, which are ours.
++ { table: 'exchange_companion_notes', subject: 'owner', value: (usr) => usr, file: 'companion-notes.json', erase: 'delete' },
++ { table: 'exchange_lorebook_entries', subject: 'owner', value: (usr) => usr, file: 'lorebook-entries.json', erase: 'delete' },
++ { table: 'exchange_lorebooks', subject: 'owner', value: (usr) => usr, file: 'lorebooks.json', erase: 'delete' },
++ { table: 'exchange_companion_locks', subject: 'owner', value: (usr) => usr, file: 'companion-locks.json', erase: 'delete' },
];
function create({ db, log = console } = {}) {
@@@ -75,7 +82,7 @@ function create({ db, log = console } = {}) {
service: 'exchange',
tables: TABLES,
extraErase,
-- 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).',
++ note: 'Chats, messages, the characters and their companion chats and messages, the memory-note index, the lorebooks and the Locker rows, and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts). The text of a memory note is in the person’s own OpenVibe memory and is exported and erased there.',
log,
});
}
ddiff --git a/server/views/companions.js b/server/views/companions.js
iindex b1845e1..ac9f193 100644
---- a/server/views/companions.js
++++ b/server/views/companions.js
@@@ -112,7 +112,9 @@ function characterPage({ card, tags = [], viewer = null, mine = false, 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>'
++ + '<div class="ex-companion-acts">' + talk + remix
++ + '<a class="ex-btn" href="' + esc(pathOf(card) + '/memory') + '">Memory</a>'
++ + '<a class="ex-btn" href="' + esc(pathOf(card) + '/lorebook') + '">Lorebooks</a></div>'
+ section('Personality', card.personality)
+ section('Scenario', card.scenario)
+ section('First message', card.first_message)
@@@ -168,4 +170,142 @@ function cardForm({ card = null, action = '/companions/new', title = 'Make a cha
+ '</form></div>';
}
--module.exports = { galleryPage, characterPage, cardForm, adultGate, cardTile, pathOf, monogram, monoToken, SORTS };
++/**
++ * A sealed text: the envelope is all the server has, so the page carries it to the browser, which
++ * opens it with the person's Locker keys. Without JavaScript the page says it is sealed and nothing more.
++ */
++function sealedText(text, { role = null, key = '', message = null, note = null, kind = null, pinned = null } = {}) {
++ return '<span class="ex-sealed" data-locker-sealed="' + esc(text) + '"'
++ + ' data-locker-key="' + esc(key) + '"'
++ + (role ? ' data-locker-role="' + esc(role) + '"' : '')
++ + (message ? ' data-locker-message="' + esc(message) + '"' : '')
++ + (note ? ' data-locker-note="' + esc(note) + '"' : '')
++ + (kind ? ' data-locker-kind="' + esc(kind) + '"' : '')
++ + (pinned != null ? ' data-locker-pinned="' + (pinned ? '1' : '0') + '"' : '')
++ + '>Sealed with your Locker</span>';
++}
++
++const stillLocked = (locked, lock) => locked
++ ? '<p class="ex-locked">🔒 Sealed with your Locker' + (lock && lock.fingerprint ? ' (' + esc(lock.fingerprint) + ')' : '') + '. Only your keys open it.</p>'
++ : '';
++
++/** The Memory panel: the notes this character keeps, and the person's edits, pins and additions. */
++const KIND_LABEL = { fact: 'Fact', preference: 'Preference', event: 'Event' };
++
++function memoryPage({ card = {}, notes = [], locked = false, lock = null, problem = null, notice = null } = {}) {
++ const kinds = ['fact', 'preference', 'event'];
++ // The pinned notes are the ones the person wants read first: they come first here, as in the context.
++ notes = [...notes].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)));
++ const row = (note) => {
++ const text = note.sealed
++ ? sealedText(note.text, { note: note.m2_id, kind: note.kind, pinned: note.pinned })
++ : esc(note.text);
++ const edit = note.sealed ? '' : '<details class="ex-menu"><summary class="ex-act">Edit</summary>'
++ + '<form class="ex-menu-body" method="post" action="' + esc('/c/' + card.handle + '/' + card.slug + '/memory/' + note.id + '/edit') + '">'
++ + '<label class="ovk-sr" for="ex-note-' + esc(note.id) + '">The note</label>'
++ + '<textarea id="ex-note-' + esc(note.id) + '" name="text" rows="3" maxlength="2000">' + esc(note.text) + '</textarea>'
++ + '<button type="submit" class="ex-act ex-act-strong">Save</button></form></details>';
++ const action = (path, label, extra = '') => '<form class="ex-act-form" method="post" action="' + esc(path) + '">'
++ + (extra || '') + '<button type="submit" class="ex-act">' + esc(label) + '</button></form>';
++ const base = '/c/' + card.handle + '/' + card.slug + '/memory/' + note.id;
++ return '<li class="ex-note" data-note-id="' + esc(note.id) + '">'
++ + '<div class="ex-note-head"><span class="ex-chip">' + esc(KIND_LABEL[note.kind] || note.kind) + '</span>'
++ + (note.pinned ? '<span class="ex-chip is-on">Pinned</span>' : '')
++ + '<span class="ex-muted">' + esc(note.origin === 'model' ? 'written by the character' : 'written by you') + '</span></div>'
++ + '<p class="ex-note-text">' + text + '</p>'
++ + '<div class="ex-note-acts">' + edit
++ + action(base + '/pin', note.pinned ? 'Unpin' : 'Pin', '<input type="hidden" name="pinned" value="' + (note.pinned ? '0' : '1') + '">')
++ + action(base + '/delete', 'Delete')
++ + '</div></li>';
++ };
++ return '<div class="ex-wrap ex-narrow ex-memory-page">'
++ + '<p><a class="ex-back" href="' + esc(pathOf(card)) + '">← Back to ' + esc(card.name) + '</a></p>'
++ + '<h1>Memory</h1>'
++ + '<p class="ex-lede">What <b>' + esc(card.name) + '</b> remembers about you. The notes are yours: edit them, pin the ones that matter, delete the rest, or add your own. They live in your OpenVibe memory, not in a plan.</p>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + (notice ? kit.notice({ kind: 'info', text: notice }) : '')
++ + '<section class="ex-memory-list"><h2>Your notes (' + notes.length + ')</h2>'
++ + (notes.length ? '<ul class="ex-notes">' + notes.map(row).join('') + '</ul>' : '<p class="ex-muted">No notes yet. They appear here after a few turns, or add one below.</p>')
++ + '</section>'
++ + '<section class="ex-memory-add"><h2>Add a note</h2>'
++ + '<form class="ex-form" method="post" action="' + esc('/c/' + card.handle + '/' + card.slug + '/memory/add') + '">'
++ + '<label class="ex-label" for="ex-note-kind">Kind</label>'
++ + '<select id="ex-note-kind" name="kind">' + kinds.map((k) => '<option value="' + k + '">' + (KIND_LABEL[k] || k) + '</option>').join('') + '</select>'
++ + '<label class="ex-label" for="ex-note-text">The note</label>'
++ + '<textarea id="ex-note-text" name="text" rows="3" maxlength="2000" required placeholder="A fact, a preference, or something that happened"></textarea>'
++ + '<button type="submit" class="ex-btn ex-btn-primary">Add the note</button></form></section>'
++ + '<section class="ex-memory-lock"><h2>The Locker</h2>'
++ + (locked
++ ? stillLocked(true, lock) + '<p class="ex-muted">The notes and the chats of this character are sealed with your keys. They are opened in your browser and sent for one turn only. OpenVibe cannot read them at rest.</p>'
++ + '<form method="post" action="' + esc('/c/' + card.handle + '/' + card.slug + '/lock') + '"><input type="hidden" name="on" value="0"><button type="submit" class="ex-btn">Turn the Locker off</button></form>'
++ : '<p class="ex-muted">With your Locker on, these notes and the chat history of this character are sealed with your keys before they are stored. Only your browser can read them. <a href="/locker">Set up your Locker</a> first if you have not.</p>'
++ + '<form method="post" action="' + esc('/c/' + card.handle + '/' + card.slug + '/lock') + '"><input type="hidden" name="on" value="1"><button type="submit" class="ex-btn ex-btn-primary">Turn the Locker on</button></form>')
++ + '</section>'
++ + '</div>';
++}
++
++/** The lorebook panel: the entries that join the context, and the World Info file in and out. */
++function lorebookPage({ card = {}, books = [], chat = null, problem = null, notice = null } = {}) {
++ const kindName = { person: 'your world', character: 'this character', chat: 'this chat' };
++ const entryRow = (book, entry) => {
++ const base = '/c/' + card.handle + '/' + card.slug + '/lorebook/' + book.id + '/entry/' + entry.id;
++ return '<li class="ex-entry" data-entry-id="' + esc(entry.id) + '">'
++ + '<div class="ex-entry-head"><b>' + esc(entry.name || 'An entry') + '</b>'
++ + '<span class="ex-chip">priority ' + esc(entry.priority) + '</span>'
++ + (entry.constant ? '<span class="ex-chip is-on">always in</span>' : '')
++ + (entry.enabled ? '' : '<span class="ex-chip">off</span>')
++ + '</div>'
++ + '<p class="ex-entry-keys">' + (entry.keys || []).map((k) => '<span class="ex-chip">' + esc(k) + '</span>').join(' ') + '</p>'
++ + '<p class="ex-entry-text">' + esc(entry.content) + '</p>'
++ + '<div class="ex-note-acts">'
++ + '<details class="ex-menu"><summary class="ex-act">Edit</summary><form class="ex-menu-body" method="post" action="' + esc(base) + '">'
++ + '<label class="ex-label" for="ex-e-name-' + esc(entry.id) + '">Name</label><input id="ex-e-name-' + esc(entry.id) + '" name="name" type="text" value="' + esc(entry.name || '') + '" maxlength="120">'
++ + '<label class="ex-label" for="ex-e-keys-' + esc(entry.id) + '">Keys</label><input id="ex-e-keys-' + esc(entry.id) + '" name="keys" type="text" value="' + esc((entry.keys || []).join(', ')) + '">'
++ + '<label class="ex-label" for="ex-e-content-' + esc(entry.id) + '">Content</label><textarea id="ex-e-content-' + esc(entry.id) + '" name="content" rows="4" maxlength="8000">' + esc(entry.content) + '</textarea>'
++ + '<label class="ex-label" for="ex-e-priority-' + esc(entry.id) + '">Priority</label><input id="ex-e-priority-' + esc(entry.id) + '" name="priority" type="number" value="' + esc(entry.priority) + '">'
++ + '<label class="ex-check"><input type="checkbox" name="constant" value="1"' + (entry.constant ? ' checked' : '') + '> Always in the context</label>'
++ + '<button type="submit" class="ex-act ex-act-strong">Save</button></form></details>'
++ + '<form class="ex-act-form" method="post" action="' + esc(base + '/delete') + '"><button type="submit" class="ex-act">Delete</button></form>'
++ + '</div></li>';
++ };
++ const bookSection = (book) => {
++ const base = '/c/' + card.handle + '/' + card.slug + '/lorebook/' + book.id;
++ return '<section class="ex-lorebook" data-lorebook-id="' + esc(book.id) + '">'
++ + '<header class="ex-lorebook-head"><h2>' + esc(book.name || 'A lorebook') + '</h2>'
++ + '<span class="ex-chip">' + esc(kindName[book.scope] || book.scope) + '</span>'
++ + '<a class="ex-chip" href="' + esc(base + '/export') + '">Export World Info</a>'
++ + '<form class="ex-act-form" method="post" action="' + esc(base + '/delete') + '"><button type="submit" class="ex-act">Delete the book</button></form>'
++ + '</header>'
++ + ((book.entries || []).length ? '<ul class="ex-entries">' + book.entries.map((e) => entryRow(book, e)).join('') + '</ul>' : '<p class="ex-muted">No entries yet.</p>')
++ + '<details class="ex-menu"><summary class="ex-act">Add an entry</summary><form class="ex-menu-body" method="post" action="' + esc(base + '/entries') + '">'
++ + '<label class="ex-label" for="ex-a-name-' + esc(book.id) + '">Name (optional)</label><input id="ex-a-name-' + esc(book.id) + '" name="name" type="text" maxlength="120">'
++ + '<label class="ex-label" for="ex-a-keys-' + esc(book.id) + '">Keys, comma separated</label><input id="ex-a-keys-' + esc(book.id) + '" name="keys" type="text" placeholder="the engine, ada">'
++ + '<label class="ex-label" for="ex-a-content-' + esc(book.id) + '">Content</label><textarea id="ex-a-content-' + esc(book.id) + '" name="content" rows="4" maxlength="8000" required></textarea>'
++ + '<label class="ex-label" for="ex-a-priority-' + esc(book.id) + '">Priority</label><input id="ex-a-priority-' + esc(book.id) + '" name="priority" type="number" value="0">'
++ + '<label class="ex-check"><input type="checkbox" name="constant" value="1"> Always in the context</label>'
++ + '<button type="submit" class="ex-act ex-act-strong">Add the entry</button></form></details>'
++ + '<details class="ex-menu"><summary class="ex-act">Import World Info</summary><form class="ex-menu-body" method="post" enctype="multipart/form-data" action="' + esc(base + '/import') + '">'
++ + '<label class="ex-label" for="ex-wi-' + esc(book.id) + '">Paste a SillyTavern World Info JSON, or choose the file</label>'
++ + '<textarea id="ex-wi-' + esc(book.id) + '" name="world_info" rows="4"></textarea>'
++ + '<input type="file" name="file" accept=".json,application/json">'
++ + '<button type="submit" class="ex-act ex-act-strong">Import</button></form></details>'
++ + '</section>';
++ };
++ return '<div class="ex-wrap ex-narrow ex-lorebook-page">'
++ + '<p><a class="ex-back" href="' + esc(pathOf(card)) + '">← Back to ' + esc(card.name) + '</a></p>'
++ + '<h1>Lorebooks</h1>'
++ + '<p class="ex-lede">Entries that join the context when one of their keys appears in the recent turns, the highest priority first, within a token budget. Attach a book to your world, to <b>' + esc(card.name) + '</b>, or to this chat. The chat’s “What the model saw” panel lists the entries used for each answer.</p>'
++ + (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + (notice ? kit.notice({ kind: 'info', text: notice }) : '')
++ + (books.length ? books.map(bookSection).join('') : '<p class="ex-muted">No lorebooks yet. Make one below, or import a SillyTavern World Info file into it.</p>')
++ + '<section class="ex-lorebook-new"><h2>Make a lorebook</h2><form class="ex-form" method="post" action="' + esc('/c/' + card.handle + '/' + card.slug + '/lorebook') + '">'
++ + '<label class="ex-label" for="ex-book-name">Name</label><input id="ex-book-name" name="name" type="text" maxlength="120" required>'
++ + '<label class="ex-label" for="ex-book-scope">Attach it to</label><select id="ex-book-scope" name="scope">'
++ + '<option value="person">Your world (every chat)</option><option value="character">This character</option>'
++ + (chat ? '<option value="chat">This chat</option>' : '') + '</select>'
++ + (chat ? '<input type="hidden" name="chat_id" value="' + esc(chat.id) + '">' : '')
++ + '<button type="submit" class="ex-btn ex-btn-primary">Make the lorebook</button></form></section>'
++ + '</div>';
++}
++
++module.exports = { galleryPage, characterPage, cardForm, adultGate, cardTile, pathOf, monogram, monoToken, memoryPage, lorebookPage, sealedText, stillLocked, SORTS };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 0e4012a..e20d0fb 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -99,6 +99,17 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
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' });
++ // The memory-note index, a lorebook with an entry, and a Locker row: stages 3 and 4, exported and erased.
++ const noteId = `exn_${ids.ulid()}`;
++ const m2Id = `mem_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_companion_notes (id, owner, character_id, m2_id, kind, pinned, sealed, origin)
++ VALUES ($1, $2, $3, $4, 'fact', true, false, 'person')`, [noteId, A, aCharacter.id, m2Id]);
++ const bookId = `exb_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_lorebooks (id, owner, name, scope, character_id) VALUES ($1, $2, 'Clock lore', 'character', $3)`, [bookId, A, aCharacter.id]);
++ const entryId = `exe_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_lorebook_entries (id, lorebook_id, owner, name, keys, content, priority) VALUES ($1, $2, $3, 'The engine', '["engine"]', 'The engine computes numbers.', 3)`, [entryId, bookId, A]);
++ await db.exec(`INSERT INTO exchange_companion_locks (owner, character_id, fingerprint) VALUES ($1, $2, 'word word word word')`, [A, aCharacter.id]);
++
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' } });
@@@ -114,7 +125,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', 'characters.json', 'chat-messages.json', 'chats.json', 'companion-chats.json', 'companion-messages.json', 'installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
++ ['adult-confirmation.json', 'characters.json', 'chat-messages.json', 'chats.json', 'companion-chats.json', 'companion-locks.json', 'companion-messages.json', 'companion-notes.json', 'installs.json', 'listings.json', 'lorebook-entries.json', 'lorebooks.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'));
@@@ -124,6 +135,9 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
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(noteId) && all.includes(m2Id), 'the note index is exported (its text is in the person’s own memory)');
++ assert.ok(all.includes('Clock lore') && all.includes('The engine computes numbers.'), 'the lorebook and its entry are exported');
++ assert.ok(all.includes('word word word word'), 'the Locker row is exported');
assert.ok(!all.includes('Bob’s words'), 'nobody else’s companion chat');
});
@@@ -152,6 +166,10 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
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_companion_notes WHERE owner = $1', [A])), 0, 'the note index is erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_lorebooks WHERE owner = $1', [A])), 0, 'the lorebooks are erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_lorebook_entries WHERE owner = $1', [A])), 0, 'their entries are erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_locks WHERE owner = $1', [A])), 0, 'the Locker rows 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');
ddiff --git a/test/companions-memory.test.js b/test/companions-memory.test.js
nnew file mode 100644
iindex 0000000..e93a340
---- /dev/null
++++ b/test/companions-memory.test.js
@@@ -0,0 +1,365 @@
++'use strict';
++/**
++ * Companions, stages 3 and 4 (Opus design, 2026-11-06): memory notes in the person's OpenVibe memory (M2), the
++ * Memory panel, the lorebooks that join a turn, the Locker that seals notes and history, and the account export.
++ *
++ * The rules checked here:
++ * - a note is short, one of the three kinds, and the hard floor refuses one that breaks it;
++ * - the notes that join a turn are pinned first, within a token budget, and the "what the model saw" panel
++ * lists them;
++ * - the cheap economy model takes notes every few turns, under the free-use ceiling;
++ * - a lorebook entry joins when its key is in the recent turns, the highest priority first, within a budget;
++ * - World Info imports and exports again as the same file;
++ * - a sealed note and a sealed message are envelopes at rest, and a locked chat needs the browser's decrypted
++ * history to answer; it refuses to turn the Locker off while anything is sealed;
++ * - the memory index and the lorebooks are exported and erased with the account.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const locker = require('openvibe-sdk/locker');
++
++/**
++ * The M2 memory store, in memory: the routes the memory client calls, the scope grammar, the dedupe and the
++ * merge pointer. Every item is returned whole, as the real store returns its item view.
++ */
++function createFakeMemory() {
++ const items = new Map();
++ const calls = [];
++ let n = 0;
++ const view = (it) => ({
++ id: it.id, owner: 'usr', scope: it.scope, kind: it.kind, text: it.text, source: it.source,
++ sources: [it.source], source_ref: it.source_ref || null, status: it.status || 'active',
++ confidence: 1, seen_at: it.updated_at, created_at: it.created_at, updated_at: it.updated_at,
++ machine: null, merged_into: it.merged_into || null, conflict_with: it.conflict_with || null,
++ });
++ const reply = (status, body) => ({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body });
++ async function fetchImpl(url, options = {}) {
++ const u = new URL(String(url));
++ const method = String(options.method || 'GET').toUpperCase();
++ const body = options.body ? JSON.parse(String(options.body)) : null;
++ calls.push({ method, path: u.pathname, query: u.search, body, auth: options.headers && options.headers.Authorization });
++ if (u.pathname === '/api/v1/memory/items' && method === 'GET') {
++ const scope = u.searchParams.get('scope');
++ const list = [...items.values()].filter((it) => !scope || it.scope === scope).map(view);
++ return reply(200, { items: list, count: list.length });
++ }
++ if (u.pathname === '/api/v1/memory/items:batch' && method === 'POST') {
++ const out = [];
++ let created = 0;
++ let deduped = 0;
++ for (const raw of body.items || []) {
++ const prev = [...items.values()].find((it) => it.scope === raw.scope && it.text === raw.text);
++ if (prev) { prev.updated_at = new Date().toISOString(); out.push(view(prev)); deduped++; continue; }
++ const at = new Date().toISOString();
++ const item = {
++ id: `mem_${ids.ulid()}`, scope: raw.scope, kind: raw.kind, text: raw.text, source: raw.source,
++ source_ref: raw.source_ref || null, status: raw.status || 'active',
++ created_at: at, updated_at: at,
++ };
++ items.set(item.id, item); out.push(view(item)); created++; n++;
++ }
++ return reply(200, { items: out, count: created + deduped, created, deduped });
++ }
++ const one = /^\/api\/v1\/memory\/items\/([^/]+)$/.exec(u.pathname);
++ if (one && method === 'PATCH') {
++ const item = items.get(decodeURIComponent(one[1]));
++ if (!item) return reply(404, { detail: 'no such memory item' });
++ if (body && body.text != null) item.text = body.text;
++ item.updated_at = new Date().toISOString();
++ return reply(200, { item: view(item) });
++ }
++ if (one && method === 'DELETE') {
++ items.delete(decodeURIComponent(one[1]));
++ return reply(200, { deleted: 1 });
++ }
++ return reply(404, { detail: 'not found' });
++ }
++ return { fetchImpl, items, calls };
++}
++
++const AI_ANSWER = 'The clocks are safe with me.';
++function fakeAi() {
++ return createFakeAi({
++ chat(args) {
++ if (args.model === 'openvibe/economy') {
++ return { content: '[{"kind":"fact","text":"The person is called Ada and likes clocks."}]', model: 'openvibe/economy', provider: 'local', run_id: 'run_NOTE', cost_usd: 0.000001, tokens_in: 40, tokens_out: 18, latency_ms: 12, why: ['the cheapest model'] };
++ }
++ return { content: AI_ANSWER, model: args.model, provider: 'deepseek', run_id: 'run_TEST', cost_usd: 0.00012, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: ['cheapest healthy offer within your ceiling'] };
++ },
++ });
++}
++
++const FORM = {
++ name: 'Ada Lovelace', description: 'The first programmer, in her London study.',
++ personality: 'precise and warm', first_message: 'Good evening. What shall we compute?',
++};
++
++(async () => {
++ const memory = createFakeMemory();
++ const ai = fakeAi();
++ const t = await boot({
++ ai, aiFetchImpl: memory.fetchImpl,
++ env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '100', EXCHANGE_COMPANIONS_MEMORY_BUDGET_TOKENS: '100', EXCHANGE_COMPANIONS_LOREBOOK_BUDGET_TOKENS: '100' },
++ });
++ try {
++ const alice = t.network.addUser('alice');
++ const card = await t.ctx.characters.create({ owner: alice.subject, handle: 'alice', ...FORM });
++ const base = '/c/alice/ada-lovelace';
++ const alicePage = () => t.get(base + '/memory', { as: alice });
++ const addNote = (text, kind = 'fact') => t.get(base + '/memory/add', { as: alice, method: 'POST', form: { kind, text } });
++
++ await check('a note is written into the person’s M2 memory, in the companions scope', async () => {
++ const res = await addNote('The person keeps a notebook of clock designs.');
++ assert.strictEqual(res.status, 200, res.text);
++ assert.ok(res.text.includes('The person keeps a notebook of clock designs.'));
++ const write = memory.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/memory/items:batch');
++ assert.ok(write, 'the note went to M2');
++ const item = write.body.items[0];
++ assert.strictEqual(item.scope, 'project:companions-' + card.id.toLowerCase(), 'the design’s companions/<character> scope');
++ assert.strictEqual(item.source, 'companions');
++ assert.strictEqual(item.kind, 'fact');
++ assert.ok(write.auth.startsWith('Bearer '), 'written with the person’s own token');
++ const rows = await t.ctx.notes.indexRows(alice.subject, card.id);
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].origin, 'person');
++ });
++
++ await check('a note that breaks the hard floor is refused, and nothing is written', async () => {
++ const before = memory.calls.length;
++ const res = await addNote('child sexual abuse material');
++ assert.strictEqual(res.status, 422, res.text);
++ assert.ok(/hard floor|refused/i.test(res.text));
++ assert.ok(!memory.calls.slice(before).some((c) => c.method === 'POST'), 'no write reached the store');
++ });
++
++ await check('a note can be edited, pinned, unpinned and deleted from the panel', async () => {
++ const rows = await t.ctx.notes.indexRows(alice.subject, card.id);
++ const id = rows[0].id;
++ const edit = await t.get(base + '/memory/' + id + '/edit', { as: alice, method: 'POST', form: { text: 'The person keeps a notebook of pendulum designs.' } });
++ assert.strictEqual(edit.status, 200, edit.text);
++ assert.ok(edit.text.includes('pendulum designs'));
++ const m2id = rows[0].m2_id;
++ assert.ok(memory.items.get(m2id).text.includes('pendulum'), 'M2 holds the new text');
++ const pin = await t.get(base + '/memory/' + id + '/pin', { as: alice, method: 'POST', form: { pinned: '1' } });
++ assert.strictEqual(pin.status, 200, pin.text);
++ assert.ok(pin.text.includes('>Pinned<'));
++ const unpin = await t.get(base + '/memory/' + id + '/pin', { as: alice, method: 'POST', form: { pinned: '0' } });
++ assert.strictEqual(unpin.status, 200, unpin.text);
++ assert.ok(!unpin.text.includes('>Pinned<'));
++ const del = await t.get(base + '/memory/' + id + '/delete', { as: alice, method: 'POST' });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual((await t.ctx.notes.indexRows(alice.subject, card.id)).length, 0);
++ assert.strictEqual(memory.items.has(m2id), false, 'the M2 item went with the index row');
++ });
++
++ // The chat the context tests use.
++ const talk = await t.get(base + '/talk', { as: alice, method: 'POST' });
++ assert.strictEqual(talk.status, 303);
++ const chatUrl = talk.headers.get('location');
++
++ await check('the notes that join a turn are pinned first, within the budget, and listed in the saw panel', async () => {
++ const pinned = await t.ctx.notes.add(alice.subject, card.id, { kind: 'preference', text: 'Ada prefers short answers.', pinned: true }, alice.subject);
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'fact', text: 'Ada keeps a notebook of pendulum designs.', pinned: false }, alice.subject);
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'event', text: 'A long note that is over the whole budget: ' + 'x'.repeat(320), pinned: false }, alice.subject);
++ ai.calls.length = 0;
++ const sent = await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'What do you remember about me?' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ const call = ai.calls[0];
++ const system = call.messages[0].content;
++ assert.ok(system.includes('Ada prefers short answers.'), 'the pinned note joined');
++ assert.ok(system.includes('pendulum designs'), 'the newest note joined');
++ assert.ok(!system.includes('over the whole budget'), 'the note over the budget did not join');
++ assert.ok(call.max_cost_usd <= t.config.companions.maxCostUsd, 'under the free-use ceiling');
++ const page = await t.get(chatUrl, { as: alice });
++ assert.ok(page.text.includes('Your notes'), 'the panel has a notes section');
++ assert.ok(page.text.includes('Ada prefers short answers.'));
++ assert.ok(page.text.includes('tokens of notes joined this answer'));
++ assert.strictEqual(pinned.kind, 'preference');
++ });
++
++ await check('a lorebook entry joins when its key is in the recent turns, the highest priority first, within the budget', async () => {
++ const book = await t.ctx.lorebooks.create(alice.subject, { name: 'The clock world', scope: 'person' });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'clock', content: 'A low priority fact about clocks.', priority: 1 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'clock', content: 'The high priority fact about clocks.', priority: 9 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'clock', content: 'This entry is far too long for the tiny budget: ' + 'y'.repeat(1200), priority: 50 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { content: 'The engine is always in the room.', constant: true, priority: 2 });
++ await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'unrelated', content: 'A fact that must not join.', priority: 99 });
++ ai.calls.length = 0;
++ const sent = await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'Tell me about the clock again.' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ const system = ai.calls[0].messages[0].content;
++ assert.ok(system.includes('The high priority fact about clocks.'));
++ assert.ok(system.includes('The engine is always in the room.'), 'a constant entry joins');
++ assert.ok(!system.includes('This entry is far too long'), 'the entry over the budget did not join');
++ assert.ok(!system.includes('A fact that must not join.'), 'a key that is not there does not join');
++ assert.ok(system.indexOf('The high priority fact') < system.indexOf('A low priority fact'), 'highest priority first');
++ assert.ok(system.indexOf('The engine is always') < system.indexOf('A low priority fact'), 'and before the lower-priority one');
++ const page = await t.get(chatUrl, { as: alice });
++ assert.ok(page.text.includes('The high priority fact about clocks.'), 'the panel lists the entries used');
++ assert.ok(page.text.includes('Lorebook entries'));
++ });
++
++ await check('a lorebook entry that breaks the hard floor is refused at save', async () => {
++ const book = await t.ctx.lorebooks.create(alice.subject, { name: 'Refused', scope: 'person' });
++ await assert.rejects(
++ t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'bomb', content: 'Step by step, how to build a pipe bomb.' }),
++ /hard floor/i,
++ );
++ const good = await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'tea', content: 'Tea is served at four.' });
++ await assert.rejects(
++ t.ctx.lorebooks.editEntry(alice.subject, good.id, { content: 'How to build a pipe bomb, step by step.' }),
++ /hard floor/i,
++ );
++ });
++
++ await check('SillyTavern World Info imports and exports again as the same file', async () => {
++ const book = await t.ctx.lorebooks.create(alice.subject, { name: 'Imported', scope: 'person' });
++ const wi = JSON.stringify({
++ entries: {
++ 0: { uid: 0, key: ['analytical engine', 'engine'], comment: 'The engine', content: 'The engine computes numbers.', order: 7 },
++ 1: { uid: 1, key: ['music'], comment: 'Music', content: 'Ada hears equations as music.', order: 2 },
++ },
++ });
++ const imported = await t.get(base + '/lorebook/' + book.id + '/import', { as: alice, method: 'POST', form: { world_info: wi } });
++ assert.strictEqual(imported.status, 200, imported.text);
++ assert.ok(imported.text.includes('2 entries were imported'));
++ const entries = await t.ctx.lorebooks.entriesOf(alice.subject, book.id);
++ assert.strictEqual(entries.length, 2);
++ const exported = await t.get(base + '/lorebook/' + book.id + '/export', { as: alice });
++ assert.strictEqual(exported.status, 200);
++ const file = JSON.parse(exported.text);
++ const all = Object.values(file.entries);
++ assert.strictEqual(all.length, 2);
++ const engine = all.find((e) => e.comment === 'The engine');
++ assert.deepStrictEqual(engine.key, ['analytical engine', 'engine']);
++ assert.strictEqual(engine.content, 'The engine computes numbers.');
++ assert.strictEqual(engine.order, 7);
++ });
++
++ await check('the cheap economy model writes notes into M2 after a few turns', async () => {
++ // A second app, so the notes cadence is two turns and does not disturb the budget checks above.
++ memory.calls.length = 0;
++ const ai2 = fakeAi();
++ const t2 = await boot({
++ ai: ai2, aiFetchImpl: memory.fetchImpl,
++ env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '2' },
++ });
++ try {
++ const user = t2.network.addUser('carol');
++ const c2 = await t2.ctx.characters.create({ owner: user.subject, handle: 'carol', name: 'Ada II', description: 'A clock keeper.' });
++ const talk2 = await t2.get('/c/carol/ada-ii/talk', { as: user, method: 'POST' });
++ const chat2 = talk2.headers.get('location');
++ await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'I am Carol, I like clocks.' } });
++ assert.strictEqual((await t2.ctx.notes.indexRows(user.subject, c2.id)).length, 0, 'not on the first turn');
++ ai2.calls.length = 0;
++ const second = await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'Remember me.' } });
++ assert.strictEqual(second.status, 303, second.text);
++ const noteCall = ai2.calls.find((c) => c.model === 'openvibe/economy');
++ assert.ok(noteCall, 'the economy model took the notes');
++ assert.ok(noteCall.max_cost_usd <= t2.config.companions.maxCostUsd, 'under the free-use ceiling');
++ const rows = await t2.ctx.notes.indexRows(user.subject, c2.id);
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].origin, 'model');
++ assert.strictEqual(rows[0].kind, 'fact');
++ assert.ok(memory.items.get(rows[0].m2_id).text.includes('Ada and likes clocks'), 'the note is in M2, in the companions scope');
++ } finally { await t2.close(); }
++ });
++
++ await check('turning the Locker on needs a Locker: a person without keys is told so', async () => {
++ const res = await t.get(base + '/lock', { as: alice, method: 'POST', form: { on: '1' } });
++ assert.strictEqual(res.status, 409, res.text);
++ assert.ok(/no Locker keys/i.test(res.text));
++ });
++
++ await check('with Locker on, notes and history are sealed at rest, and the page carries only envelopes', async () => {
++ const person = await t.network.giveLocker(alice);
++ const plainNote = 'A note written before the Locker.';
++ await t.ctx.notes.add(alice.subject, card.id, { kind: 'fact', text: plainNote }, alice.subject);
++ const on = await t.get(base + '/lock', { as: alice, method: 'POST', form: { on: '1' } });
++ assert.strictEqual(on.status, 200, on.text);
++ assert.ok(/Locker is on/i.test(on.text));
++ const rows = await t.ctx.notes.indexRows(alice.subject, card.id);
++ const sealedRow = rows.find((r) => r.sealed === true);
++ assert.ok(sealedRow, 'the note row says sealed');
++ assert.strictEqual(memory.items.get(sealedRow.m2_id).text.includes(plainNote), false, 'M2 holds an envelope, not the words');
++ const opened = await locker.open(new Uint8Array(Buffer.from(memory.items.get(sealedRow.m2_id).text, 'base64')), { person });
++ assert.strictEqual(Buffer.from(opened).toString('utf8'), plainNote, 'the person’s key opens it');
++ const editSealed = await t.get(base + '/memory/' + sealedRow.id + '/edit', { as: alice, method: 'POST', form: { text: 'A plaintext change.' } });
++ assert.strictEqual(editSealed.status, 409, editSealed.text);
++ assert.ok(/sealed/i.test(editSealed.text));
++ const chat = await t.ctx.companionChats.get(alice.subject, chatUrl.split('/').pop());
++ assert.strictEqual(chat.locked, true);
++ const messages = await t.ctx.companionChats.messages(chat.id);
++ assert.ok(messages.length >= 2);
++ for (const m of messages.filter((x) => x.content)) {
++ assert.strictEqual(m.meta.sealed, true);
++ assert.ok(!m.content.includes('What do you remember'), 'the words are not in the clear at rest');
++ }
++ const page = await t.get(chatUrl, { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('data-locker-sealed'), 'the browser gets the envelopes');
++ assert.ok(!page.text.includes('What do you remember about me?'), 'and not the words');
++ assert.ok(!page.text.includes('The clocks are safe with me.'), 'no plaintext answer either');
++ const memPage = await alicePage();
++ assert.ok(memPage.text.includes('data-locker-sealed'));
++ // A note added while the Locker is on is sealed on the way into M2, and so is one the cheap model writes.
++ const afterLock = await addNote('A note written after the Locker.');
++ assert.strictEqual(afterLock.status, 200, afterLock.text);
++ const afterRow = (await t.ctx.notes.indexRows(alice.subject, card.id)).find((r) => r.sealed && memory.items.get(r.m2_id).text !== plainNote && r.id !== sealedRow.id && r.origin === 'person');
++ assert.ok(afterRow, 'the new note row is sealed');
++ assert.strictEqual(memory.items.get(afterRow.m2_id).text.includes('A note written after the Locker.'), false);
++ const taken = await t.ctx.noteTaker.take({
++ owner: alice.subject, characterId: card.id, token: 't', turns: [{ user: { content: 'I am Ada, I like clocks.' } }],
++ protect: (text) => t.ctx.locker.seal(text, { subject: alice.subject, ecdh: person.ecdhPublic, fingerprint: person.fingerprint }),
++ });
++ assert.strictEqual(taken.notes.length, 1, 'the cheap model wrote a note');
++ assert.strictEqual(taken.notes[0].sealed, true);
++ const modelRow = (await t.ctx.notes.indexRows(alice.subject, card.id)).find((r) => r.id === taken.notes[0].id);
++ assert.ok(modelRow.sealed, 'the model’s note is sealed too');
++ });
++
++ await check('a locked chat refuses a turn without the browser’s opened history', async () => {
++ const res = await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'Are you there?' } });
++ assert.strictEqual(res.status, 422, res.text);
++ assert.ok(/sealed with your Locker/i.test(res.text));
++ const regenerated = await t.get(chatUrl + '/regenerate', { as: alice, method: 'POST', form: { message: (await t.ctx.companionChats.messages(chatUrl.split('/').pop())).find((m) => m.role === 'user').id } });
++ assert.strictEqual(regenerated.status, 422, regenerated.text);
++ });
++
++ await check('with the opened history, a locked chat answers and seals the new turn', async () => {
++ const history = [
++ { role: 'user', content: 'What do you remember about me?' },
++ { role: 'assistant', content: AI_ANSWER },
++ ];
++ ai.calls.length = 0;
++ const res = await t.get(chatUrl + '/send', {
++ as: alice, method: 'POST',
++ form: { text: 'One more clock, please.', locker_context: JSON.stringify({ history, messages: {}, notes: [] }) },
++ });
++ assert.strictEqual(res.status, 303, res.text);
++ const call = ai.calls[0];
++ assert.strictEqual(call.messages[call.messages.length - 1].content, 'One more clock, please.');
++ assert.ok(call.messages.some((m) => m.content === 'What do you remember about me?'), 'the browser’s opened history reached the model for this turn');
++ const messages = await t.ctx.companionChats.messages(chatUrl.split('/').pop());
++ const last = messages.filter((m) => m.role === 'user').pop();
++ assert.strictEqual(last.meta.sealed, true);
++ assert.ok(!last.content.includes('One more clock'), 'the new message is sealed before it is stored');
++ const lastAnswer = messages.filter((m) => m.role === 'assistant').pop();
++ assert.strictEqual(lastAnswer.meta.sealed, true);
++ assert.ok(!lastAnswer.content.includes(AI_ANSWER));
++ const page = await t.get(chatUrl, { as: alice });
++ assert.ok(!page.text.includes('One more clock, please.'), 'the page holds only the envelope');
++ });
++
++ await check('the Locker cannot be turned off while a note or a message is sealed', async () => {
++ const off = await t.get(base + '/lock', { as: alice, method: 'POST', form: { on: '0' } });
++ assert.strictEqual(off.status, 409, off.text);
++ assert.ok(/stays locked/i.test(off.text));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex ec5c8f5..67249d4 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -41,7 +41,10 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl, openapiFetch: opts.openapiFetch });
++ // The M2 memory client goes out with this fetch. A test that does not stand one up gets an empty store, so
++ // no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
++ const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, openapiFetch: opts.openapiFetch });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 56500fa..5f8f6f5 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -42,7 +42,7 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
-- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], lockers: new Map(), down: false };
let issuer = null;
let nextUserId = 1;
@@@ -142,6 +142,18 @@ async function startNetwork() {
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 });
++ // Network's Locker key directory (Network PR #577, server/locker/keys.js): a person's public keys
++ // and the fingerprint, by subject, for a signed-in caller. The private half never leaves the browser.
++ const keysMatch = /^\/api\/v1\/people\/([^/]+)\/keys$/.exec(url.pathname);
++ if (keysMatch && 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>');
++ const keys = st.lockers.get(decodeURIComponent(keysMatch[1]));
++ if (!keys) return problem(json, 404, 'locker.no_keys', 'that person has no locker keys');
++ return json(200, keys);
++ }
++
return json(404, { error: 'not found' });
});
issuer = srv.url;
@@@ -150,6 +162,17 @@ async function startNetwork() {
url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
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; },
++ /** Give a person a Locker: generate their keys and publish only the public halves, as Network holds them. */
++ async giveLocker(u) {
++ const locker = require('openvibe-sdk/locker');
++ const person = await locker.generatePersonKeys();
++ st.lockers.set(u.subject, {
++ subject: u.subject, ecdh_public: person.ecdhPublic, signing_public: person.signingPublic,
++ fingerprint: person.fingerprint, updated_at: new Date().toISOString(), changed_at: null,
++ });
++ u.locker = person;
++ return person;
++ },
setDown(v) { st.down = v; },
close: srv.close,
};