openvibers/openvibe.ai Commits
Memory M2a: the sealed store, the agent search and the profile cache (#209)
Changes
@@@ -84,6 +84,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000# stored key unreadable: the storing service then has to send them again.#AI_CREDENTIALS_KEY=++# ── Memory (server/memory/, Memory M2a) ──++# 64 hex characters (openssl rand -hex 32). Memory item text and source references are AES-256-GCM sealed with it;++# the dedupe MAC is HKDF-SHA256 (info "memory-mac") of the same key. Unset turns /api/v1/memory off (503++# memory.unavailable). A key of its own, so credentials and memory rotate separately. Do not set it in production until++# the Memory M2a review; changing it makes existing items unreadable.++#AI_MEMORY_KEY=++# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_pricing_json,# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day).@@@ -49,15 +49,16 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cppand the local model server `openvibe-llm.service` on this host--- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.74.0 (service tokens, events outbox),-- `openvibe-shared` v2.3.1, pinned by release tarball++- `openvibe-contracts` v0.252.0, `openvibe-sdk` v0.75.0 (service tokens, events outbox, redact),++ `openvibe-shared` v4.53.0, pinned by release tarball## CapabilitiesImplemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),--`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; public,++`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,++`/api/v1/memory/*`) and `ai.app.run` (developer apps, ADR-014; public,registered since openvibe-contracts 0.109.0). A service token's `ns`claim limits which workflow namespaces it may run; an app token is fixed to its project's.@@@ -111,6 +112,8 @@ idempotent replay):| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) || `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) || `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) |++| `POST /api/v1/memory/items:batch`, `GET/PATCH/DELETE /api/v1/memory/items[/:id]`, `DELETE /api/v1/memory/items?source=`, `GET /api/v1/memory/profile` | `ai.memory.read` / `ai.memory.write` (the person's own token, or a service acting `on_behalf_of` them) |++| `GET /api/v1/memory/search?q=&scopes=` — active items only, at most 8, in the grant's scopes, audited | `ai.memory.read` plus `on_behalf_of` || `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` || `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` || `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |@@@ -203,6 +206,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser- Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that isa small local model without tools, so a coding agent's loop needs its project's own provider.++## Memory++++Memory M2a (spec `research-2026-10-30/memory-m2-spec.md`): a person's own notes, sealed with `AI_MEMORY_KEY`++(AES-256-GCM, AAD `memory|<owner>|<id>|<scope>`, `m1:`), deduped by an HMAC of the normalized text (HKDF-SHA256, info++`memory-mac`), and scoped by `user:<usr_…>` on every query. The server redacts every item again with++`openvibe-sdk/redact` (the Node's shapes and corpus) and refuses an item that is only a secret; what an agent writes is++a suggestion, and every item a service writes passes `noLooks`. An agent's search is active-only, at most 8 items, in++the scopes its token's grant names (`user` always; `project:*` when the grant lists it), and leaves a `memory_reads`++row with ids, never text. The profile cache rebuilds only when its `items_hash` moves, at most once an hour, in-process++on the service's own chat route (no run row, no person allowance). The account export decrypts the person's items for++their own export; a deletion removes the rows. Unset `AI_MEMORY_KEY` turns every memory route into 503++`memory.unavailable`; no default key exists.++## Anthropic-compatible endpoint`POST /v1/messages` and `GET /v1/models` (`server/api/messages.js`), so a client built for Anthropic's Messages API@@@ -641,6 +657,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key isAES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned(a four-character hint only). No response, log or console page carries a secret value.++- **Memory.** Item text is sealed with `AI_MEMORY_KEY` and bound to its owner, id and scope; plaintext exists only++ inside a request, and the profile builder calls the provider in-process so the notes are not written to a run.- **Private data.** The request log keeps hashes, not prompts; raw prompts and inline images are notkept; the cache never crosses requester, actor, target or attribution; `history: false` keepsneither input nor cache entry.nnew file mode 100644@@@ -0,0 +1,44 @@++-- phase: expand++-- OpenVibe Memory M2a (spec research-2026-10-30/memory-m2-spec.md): the person's memory store, one row per item,++-- sealed at rest with AI_MEMORY_KEY. `owner` is `user:usr_…`; every query is scoped by it. `text_enc` and++-- `source_ref_enc` are `m1:` AES-256-GCM (AAD memory|<owner>|<id>|<scope>); `text_mac` is HMAC-SHA256 of the++-- normalized text, for dedupe without reading plaintext. `memory_profiles` is a cache keyed by owner and scope;++-- `memory_reads` is the audit of what an agent read (item ids, never text).++CREATE TABLE IF NOT EXISTS memory_items (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ scope text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL, -- preference | fact | project_rule | procedure | decision | lesson++ text_enc text COLLATE "C" NOT NULL, -- m1:<iv>:<tag>:<body>++ text_mac text COLLATE "C" NOT NULL, -- HMAC-SHA256 of the normalized text++ source text COLLATE "C" NOT NULL, -- adapter id or service++ source_ref_enc text COLLATE "C", -- sealed { path, heading }++ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | suggested | archived | conflict++ confidence double precision NOT NULL DEFAULT 1,++ seen_at text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ machine text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS memory_items_owner_scope_status ON memory_items (owner, scope, status);++CREATE INDEX IF NOT EXISTS memory_items_owner_text_mac ON memory_items (owner, text_mac);++++CREATE TABLE IF NOT EXISTS memory_profiles (++ owner text COLLATE "C" NOT NULL,++ scope text COLLATE "C" NOT NULL,++ text_enc text COLLATE "C" NOT NULL,++ items_hash text COLLATE "C" NOT NULL,++ built_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (owner, scope)++);++++CREATE TABLE IF NOT EXISTS memory_reads (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ actor text COLLATE "C" NOT NULL, -- svc:codes, …++ purpose text COLLATE "C", -- the task or run the person sees++ scopes text COLLATE "C" NOT NULL DEFAULT '[]', -- JSON array of the scopes searched++ item_ids text COLLATE "C" NOT NULL DEFAULT '[]', -- JSON array; never text++ created_at text COLLATE "C" NOT NULL++);++CREATE INDEX IF NOT EXISTS memory_reads_owner_created ON memory_reads (owner, created_at);@@@ -13,8 +13,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0","pg": "^8.23.0"},@@@ -1540,9 +1540,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.241.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",-- "integrity": "sha512-FRTfO31tBk2znR/uvDM1PuLQ1mXkBSuJq/bTbN4wcYCMWXdOrbRprGMFJfuAIIp/7dfSnjxo0A0yiYk7g/9nEQ==",++ "version": "0.252.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",++ "integrity": "sha512-+b51PEN0xTiPlHtkrBpLq0cGP4bCZD2Irg0lAqm7CxA6QdIe2NFSq/kGsF6MmYXlGxIrOoe5Nw0u72xUDjgQSg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1556,9 +1556,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.74.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",++ "version": "0.75.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",++ "integrity": "sha512-XNuWLLMPHmQFQUS1abjo0As7bRM+bpr4K6EQAHX7CIMHAbkRJK0gF16yfRuCJG/G59oK+I3I19yhDDBYzL/2zw==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -25,8 +25,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0","pg": "^8.23.0"},@@@ -28,6 +28,11 @@ const TABLES = [{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },++ // Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is++ // never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.++ { table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },++ { table: 'memory_profiles', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },++ { table: 'memory_reads', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },@@@ -58,8 +63,17 @@ async function extraErase(t, subjects, counts) {counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));}--function create({ db, log = console } = {}) {-- return createAccountData({ db, service: 'ai', tables: TABLES, extraExport, extraErase, log });++function create({ db, log = console, memory = null } = {}) {++ // Memory text is sealed; the person's own export gets it decrypted from the module that holds the key. Without the++ // key the rest of the account data still exports, and the memory file says it is unavailable (never any text).++ const exportAll = async (handle, subject) => {++ const files = await extraExport(handle, subject);++ if (!memory) return files;++ try { files.push(...(await memory.exportFor(subject))); }++ catch (err) { log.warn(`[account-data] memory export unavailable: ${err.code || err.message}`); files.push({ name: 'memory-items.json', content: { unavailable: 'AI_MEMORY_KEY is not configured on this service' } }); }++ return files;++ };++ return createAccountData({ db, service: 'ai', tables: TABLES, extraExport: exportAll, extraErase, log });}module.exports = { create, TABLES, TOPICS, extraExport, extraErase };@@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');const home = require('./home');const pkg = require('../package.json');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', 'loopback');@@@ -109,6 +109,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).app.use(require('./api/messages').messagesRouter({ runs, auth, config, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ if (memory) app.use(require('./memory').memoryRouter({ memory, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));@@@ -58,6 +58,8 @@ function load(env = process.env) {issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),++ // The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.++ memoryKey: String(env.AI_MEMORY_KEY || '').trim(),networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,audience: 'openvibe.ai',@@@ -74,14 +74,16 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).const keys = createNetworkKeys({ config });const auth = createAuth({ config, log });++ // The person's memory (Memory M2a): sealed item text, the profile cache and the agent-read audit.++ const memory = require('./memory').createMemory({ db, config, clock, log, registry, pool });// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).-- const accountData = accountDataLib.create({ db, log });++ const accountData = accountDataLib.create({ db, log, memory });const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets, memory });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -144,7 +146,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, close };}if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,567 @@++'use strict';++/**++ * OpenVibe Memory M2a: the person's memory store (spec research-2026-10-30/memory-m2-spec.md).++ *++ * The text belongs to one person. It is sealed with AI_MEMORY_KEY (AES-256-GCM, `m1:` + iv + tag + body) and bound++ * to owner, item id and scope as additional data, so a row moved to another person or scope does not decrypt. The++ * dedupe MAC is HMAC-SHA256 of the normalized text, keyed from the same secret through HKDF-SHA256 (info++ * `memory-mac`), so dedupe never needs plaintext. Plaintext exists only while one request runs: it is never logged,++ * never in an error message, never in a span or an event payload.++ *++ * The API:++ * POST /api/v1/memory/items:batch import up to 200 items (person token, or a service acting on_behalf_of)++ * GET /api/v1/memory/items the person's own items (?scope=&status=&q=; q is in-process, max 2,000 rows)++ * GET /api/v1/memory/items/:id++ * PATCH /api/v1/memory/items/:id text, kind, scope or status++ * DELETE /api/v1/memory/items/:id++ * DELETE /api/v1/memory/items?source= everything from one source++ * GET /api/v1/memory/profile?scope= the cached profile, rebuilt by the cheap tier only when its items_hash moved,++ * at most once an hour per owner and scope++ * GET /api/v1/memory/search?q=&scopes= for agents (ai.memory.read + on_behalf_of): active items only, in the++ * granted scopes, at most 8, audited in memory_reads with ids and never text++ *++ * Every SQL query that reads or changes a memory table is scoped by `owner = $…` (test/memory.test.js greps for it).++ * The account export decrypts the person's own items for their own export; deletion removes the rows.++ */++const crypto = require('crypto');++const express = require('express');++const { ids, http, capabilities } = require('openvibe-contracts');++const { verifyUserToken } = require('openvibe-sdk/auth');++const { redact, onlySecret } = require('openvibe-sdk/redact');++const { noLooks } = require('openvibe-sdk/no-looks');++const { AiError, iso, sha256 } = require('../util');++const { bearer, isAppClaims, principalSubject } = require('../auth');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SCOPE_RE = /^(user|project:[a-z0-9][a-z0-9._-]{0,63})$/;++const SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;++const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];++const STATUSES = ['active', 'suggested', 'archived', 'conflict'];++/** Items from these sources were produced by an AI, so they are suggestions and pass noLooks. */++const AI_SOURCES = new Set(['actor', 'vibe', 'know']);++const PROFILE_INTERVAL_MS = 60 * 60 * 1000;++const PROFILE_ID = 'profile';++const MAX_BATCH = 200;++const MAX_LIST = 2000;++const MAX_SEARCH = 8;++++const READ_CAP = (claims) => capabilities.check(claims, 'ai.memory.read');++const WRITE_CAP = (claims) => capabilities.check(claims, 'ai.memory.write');++++function unauthorized(detail) { return new AiError(401, 'token.invalid', detail || 'a Network token (audience openvibe.ai) or a person token is required'); }++++/** The grant a service token names for an agent's memory search: `user` always, plus the exact scopes it lists. */++function grantedScopes(claims) {++ const out = new Set(['user']);++ const list = Array.isArray(claims.memory_scopes) ? claims.memory_scopes : Array.isArray(claims.scopes) ? claims.scopes : [];++ for (const s of list) if (typeof s === 'string' && SCOPE_RE.test(s)) out.add(s);++ const project = claims.project_slug || claims.project;++ if (typeof project === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(project)) out.add(`project:${project}`);++ // A run that names its project in its namespace grant (project:demo or project.demo) may read that project's scope.++ for (const n of Array.isArray(claims.ns) ? claims.ns : []) {++ const m = /^project[:.]([a-z0-9][a-z0-9._-]{0,63})$/.exec(String(n));++ if (m) out.add(`project:${m[1]}`);++ }++ return out;++}++++function createMemory({ db, config, clock = { now: () => Date.now() }, log = console, registry = null, pool = null, buildProfile = null }) {++ let macCache = null;++ let macCacheFor = null;++++ /** The 64-hex key, or 503: no default key is ever invented. */++ function secretKey() {++ const hex = String(config.memoryKey || '');++ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'memory.unavailable', 'memory is not configured on this service (AI_MEMORY_KEY)');++ return Buffer.from(hex, 'hex');++ }++ function assertConfigured() { secretKey(); }++ function macKey() {++ const hex = String(config.memoryKey || '');++ if (macCacheFor !== hex || !macCache) {++ macCache = Buffer.from(crypto.hkdfSync('sha256', secretKey(), Buffer.alloc(0), Buffer.from('memory-mac'), 32));++ macCacheFor = hex;++ }++ return macCache;++ }++++ /** `memory|<owner>|<id>|<scope>`: a row moved to another owner or scope does not open. */++ function aadFor(owner, id, scope) { return `memory|${owner}|${id}|${scope}`; }++++ function seal(plain, aad) {++ const iv = crypto.randomBytes(12);++ const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);++ c.setAAD(Buffer.from(aad));++ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);++ return `m1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;++ }++++ /** Opens a sealed value; every failure is the same, and no plaintext or ciphertext goes into the error. */++ function open(sealed, aad) {++ const parts = String(sealed || '').split(':');++ if (parts.length !== 4 || parts[0] !== 'm1') throw new AiError(500, 'memory.unreadable', 'a memory row does not open');++ try {++ const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), Buffer.from(parts[1], 'base64'));++ d.setAAD(Buffer.from(aad));++ d.setAuthTag(Buffer.from(parts[2], 'base64'));++ return Buffer.concat([d.update(Buffer.from(parts[3], 'base64')), d.final()]).toString('utf8');++ } catch { throw new AiError(500, 'memory.unreadable', 'a memory row does not open'); }++ }++++ /**++ * The statements below write PostgreSQL's `$n` placeholders, which is what the spec's owner-scope grep looks for;++ * the SDK's db layer compiles SQLite-style `?` placeholders, so this adapter maps the written `$n` to `?` and++ * passes the arguments in the order the statement names them.++ */++ function q(sql) {++ const order = [];++ let text = '';++ let last = 0;++ for (const m of sql.matchAll(/\$(\d+)/g)) {++ text += sql.slice(last, m.index) + '?';++ order.push(Number(m[1]) - 1);++ last = m.index + m[0].length;++ }++ text += sql.slice(last);++ const stmt = db.prepare(text);++ const args = (values) => order.map((i) => values[i]);++ return {++ get: (...values) => stmt.get(...args(values)),++ all: (...values) => stmt.all(...args(values)),++ run: (...values) => stmt.run(...args(values)),++ };++ }++++ const normalize = (text) => String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ const macOf = (text) => crypto.createHmac('sha256', macKey()).update(normalize(text)).digest('hex');++++ function validateScope(scope, fallback = 'user') {++ const s = String(scope == null || scope === '' ? fallback : scope);++ if (!SCOPE_RE.test(s)) throw new AiError(422, 'input.invalid', 'scope must be `user` or `project:<slug>` (never an absolute path)');++ return s;++ }++ function validateKind(kind, fallback = 'fact') {++ const k = String(kind == null || kind === '' ? fallback : kind);++ if (!KINDS.includes(k)) throw new AiError(422, 'input.invalid', `kind must be one of ${KINDS.join(', ')}`);++ return k;++ }++ function validateStatus(status, fallback = 'active') {++ const s = String(status == null || status === '' ? fallback : status);++ if (!STATUSES.includes(s)) throw new AiError(422, 'input.invalid', `status must be one of ${STATUSES.join(', ')}`);++ return s;++ }++ function validateSource(source) {++ const s = String(source == null ? '' : source).trim();++ if (!SOURCE_RE.test(s)) throw new AiError(422, 'input.invalid', 'source must be an adapter id or service name');++ return s;++ }++ function validateSourceRef(ref) {++ if (ref == null) return null;++ if (typeof ref !== 'object' || Array.isArray(ref)) throw new AiError(422, 'input.invalid', 'source_ref must be { path, heading }');++ const out = {};++ if (ref.path != null) out.path = String(ref.path).slice(0, 500);++ if (ref.heading != null) out.heading = String(ref.heading).slice(0, 300);++ if (!out.path && !out.heading) throw new AiError(422, 'input.invalid', 'source_ref must name a path or a heading');++ return out;++ }++ function validateTime(value, fallback) {++ if (value == null) return fallback;++ const d = new Date(value);++ if (Number.isNaN(d.getTime())) throw new AiError(422, 'input.invalid', 'a time is not an ISO date-time');++ return d.toISOString();++ }++++ function itemView(row) {++ const text = open(row.text_enc, aadFor(row.owner, row.id, row.scope));++ let sourceRef = null;++ if (row.source_ref_enc) {++ try { sourceRef = JSON.parse(open(row.source_ref_enc, aadFor(row.owner, row.id, row.scope))); } catch (err) { throw err instanceof AiError ? err : new AiError(500, 'memory.unreadable', 'a memory row does not open'); }++ }++ return {++ id: row.id, owner: row.owner, scope: row.scope, kind: row.kind, text,++ source: row.source, source_ref: sourceRef, status: row.status, confidence: Number(row.confidence),++ seen_at: row.seen_at, created_at: row.created_at, updated_at: row.updated_at,++ machine: row.machine || null,++ ...(AI_SOURCES.has(String(row.source).split(',')[0]) ? { no_looks: true } : {}),++ };++ }++++ function requireScope(req, scope) {++ if (req.principal.kind === 'user') return;++ if (!grantedScopes(req.principal.claims).has(scope)) throw new AiError(403, 'memory.scope_denied', `the token's grant does not name ${scope}`);++ }++++ /** Import up to 200 items. Dedupe is by text_mac within owner and scope; a repeat updates seen_at and the source. */++ async function batch(req, owner) {++ assertConfigured();++ const items = req.body && req.body.items;++ if (!Array.isArray(items) || items.length < 1 || items.length > MAX_BATCH) throw new AiError(422, 'input.invalid', `items must be an array of 1 to ${MAX_BATCH} items`);++ const serviceCaller = req.principal.kind !== 'user';++ const at = iso(clock.now());++ // Redact and refuse before writing anything: one secret-only item refuses the batch.++ const prepared = items.map((raw, index) => {++ if (!raw || typeof raw !== 'object') throw new AiError(422, 'input.invalid', `items[${index}] is not an object`);++ const clean = redact(String(raw.text == null ? '' : raw.text)).text;++ if (!clean.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty`);++ if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);++ const scope = validateScope(raw.scope, 'user');++ requireScope(req, scope);++ // A service writes under its own name, never a body source; the person's own token keeps the body source.++ const serviceSource = req.principal.subject && req.principal.subject.id++ ? req.principal.subject.id++ : String(req.principal.sub || '').replace(/^svc:/, '');++ const source = validateSource(serviceCaller ? serviceSource : (raw.source || 'person'));++ // A service is an agent: what it writes is a suggestion, and it passes noLooks. A person may mark their own++ // item as AI-produced too (the Node imports what a coding tool wrote).++ const aiMade = serviceCaller || raw.no_looks === true || AI_SOURCES.has(source);++ let text = aiMade ? noLooks(clean) : clean;++ text = String(text == null ? '' : text);++ if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty after no-looks`);++ if (onlySecret(text)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);++ const askedConfidence = raw.confidence == null ? null : Number(raw.confidence);++ const confidence = askedConfidence == null || !Number.isFinite(askedConfidence)++ ? (serviceCaller ? 0.5 : 1)++ : Math.max(0, Math.min(serviceCaller ? 0.5 : 1, askedConfidence));++ return {++ id: `mem_${ids.ulid()}`,++ scope,++ kind: validateKind(raw.kind),++ text,++ mac: macOf(text),++ source,++ sourceRef: validateSourceRef(raw.source_ref),++ // A service or an AI may only suggest until the person accepts it; the body cannot raise that.++ status: serviceCaller || aiMade ? 'suggested' : validateStatus(raw.status, 'active'),++ confidence,++ seenAt: validateTime(raw.seen_at, at),++ machine: raw.machine == null ? null : String(raw.machine).slice(0, 100),++ };++ });++ const out = [];++ let created = 0;++ let deduped = 0;++ await db.tx(async () => {++ for (const it of prepared) {++ const prev = await q('SELECT * FROM memory_items WHERE owner = $1 AND scope = $2 AND text_mac = $3 LIMIT 1').get(owner, it.scope, it.mac);++ if (prev) {++ const sources = String(prev.source || '').split(',').filter(Boolean);++ if (!sources.includes(it.source)) sources.push(it.source);++ await q('UPDATE memory_items SET seen_at = $1, updated_at = $2, source = $3 WHERE owner = $4 AND id = $5')++ .run(it.seenAt > prev.seen_at ? it.seenAt : prev.seen_at, at, sources.join(',').slice(0, 500), owner, prev.id);++ deduped++;++ out.push(itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, prev.id)));++ continue;++ }++ await q(`INSERT INTO memory_items (id, owner, scope, kind, text_enc, text_mac, source, source_ref_enc, status, confidence, seen_at, created_at, updated_at, machine)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`)++ .run(it.id, owner, it.scope, it.kind, seal(it.text, aadFor(owner, it.id, it.scope)), it.mac, it.source,++ it.sourceRef ? seal(JSON.stringify(it.sourceRef), aadFor(owner, it.id, it.scope)) : null,++ it.status, it.confidence, it.seenAt, at, at, it.machine);++ created++;++ out.push(itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, it.id)));++ }++ });++ return { items: out, count: created + deduped, created, deduped };++ }++++ async function list(req, owner) {++ assertConfigured();++ const scope = req.query.scope ? validateScope(req.query.scope) : null;++ if (scope) requireScope(req, scope);++ const status = req.query.status ? validateStatus(req.query.status) : null;++ const allowed = [...grantedScopes(req.principal.claims)];++ let sql = 'SELECT * FROM memory_items WHERE owner = $1';++ const args = [owner];++ if (scope) { args.push(scope); sql += ` AND scope = $${args.length}`; }++ else if (req.principal.kind !== 'user') { args.push(allowed); sql += ` AND scope = ANY($${args.length}::text[])`; }++ if (status) { args.push(status); sql += ` AND status = $${args.length}`; }++ sql += ' ORDER BY updated_at DESC LIMIT 2000';++ const rows = await q(sql).all(...args);++ const searchText = req.query.q == null ? '' : String(req.query.q).slice(0, 200);++ const items = [];++ for (const row of rows.slice(0, MAX_LIST)) {++ const view = itemView(row);++ if (!searchText || view.text.toLowerCase().includes(searchText.toLowerCase())) items.push(view);++ if (items.length >= 200) break;++ }++ return { items, count: items.length };++ }++++ async function get(req, owner, id) {++ assertConfigured();++ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);++ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');++ requireScope(req, row.scope);++ return { item: itemView(row) };++ }++++ async function patch(req, owner, id) {++ assertConfigured();++ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);++ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');++ requireScope(req, row.scope);++ const body = req.body || {};++ const sets = [];++ const args = [];++ const add = (column, value) => { args.push(value); sets.push(`${column} = $${args.length}`); };++ let nextScope = row.scope;++ let nextText = null;++ if (body.scope !== undefined) {++ nextScope = validateScope(body.scope);++ requireScope(req, nextScope);++ add('scope', nextScope);++ }++ if (body.kind !== undefined) add('kind', validateKind(body.kind));++ if (body.status !== undefined) add('status', validateStatus(body.status));++ if (body.text !== undefined) {++ const clean = redact(String(body.text)).text;++ if (!clean.trim()) throw new AiError(422, 'input.invalid', 'text is empty');++ if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');++ nextText = body.no_looks === true ? String(noLooks(clean)) : clean;++ if (onlySecret(nextText)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');++ add('text_enc', seal(nextText, aadFor(owner, row.id, nextScope)));++ add('text_mac', macOf(nextText));++ }++ if (body.source !== undefined) add('source', validateSource(body.source));++ if (body.source_ref !== undefined) {++ const ref = validateSourceRef(body.source_ref);++ add('source_ref_enc', ref ? seal(JSON.stringify(ref), aadFor(owner, row.id, nextScope)) : null);++ }++ if (!sets.length) throw new AiError(422, 'input.invalid', 'nothing to change: send text, kind, scope, status, source or source_ref');++ args.push(iso(clock.now()));++ const updatedAt = `$${args.length}`;++ // Re-seal when the scope changed even if only the scope was sent: the AAD names it.++ if (nextScope !== row.scope && nextText === null && !sets.some((s) => s.startsWith('text_enc'))) {++ const text = open(row.text_enc, aadFor(owner, row.id, row.scope));++ add('text_enc', seal(text, aadFor(owner, row.id, nextScope)));++ if (row.source_ref_enc) {++ const ref = open(row.source_ref_enc, aadFor(owner, row.id, row.scope));++ add('source_ref_enc', seal(ref, aadFor(owner, row.id, nextScope)));++ }++ }++ args.push(owner, row.id);++ await q(`UPDATE memory_items SET ${sets.join(', ')}, updated_at = ${updatedAt} WHERE owner = $${args.length - 1} AND id = $${args.length}`).run(...args);++ // A profile built from the old text, kind or scope will not match its items_hash again: drop it now.++ const scopes = [...new Set([row.scope, nextScope])];++ await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = ANY($2::text[])').run(owner, scopes);++ return { item: itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, row.id)) };++ }++++ async function remove(req, owner, id) {++ assertConfigured();++ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);++ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');++ requireScope(req, row.scope);++ await db.tx(async () => {++ await q('DELETE FROM memory_items WHERE owner = $1 AND id = $2').run(owner, row.id);++ await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = $2').run(owner, row.scope);++ });++ return { deleted: 1 };++ }++++ async function removeBySource(req, owner) {++ assertConfigured();++ const source = validateSource(req.query.source);++ const rows = await q("SELECT DISTINCT scope FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").all(owner, source);++ const scopes = rows.map((r) => r.scope);++ let deleted = 0;++ await db.tx(async () => {++ deleted = Number((await q("SELECT COUNT(*)::int AS n FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").get(owner, source)).n) || 0;++ await q("DELETE FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").run(owner, source);++ if (scopes.length) await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = ANY($2::text[])').run(owner, scopes);++ });++ return { deleted };++ }++++ /** Items from one scope, active only: what a profile is built from and what an agent may see. */++ async function activeRows(owner, scope) {++ return await q("SELECT * FROM memory_items WHERE owner = $1 AND scope = $2 AND status = 'active' ORDER BY updated_at DESC LIMIT 2000").all(owner, scope);++ }++++ function itemsHash(macs) {++ return sha256([...macs].sort().join('\n'));++ }++++ async function summarize({ owner, scope, items }) {++ if (buildProfile) return await buildProfile({ owner, scope, items });++ const notes = items.map((it) => `- (${it.kind}) ${it.text}`).join('\n').slice(0, 40000);++ const prompt = [++ `Write a short profile of the person from their own memory notes for ${scope}.`,++ 'Rules: summarize only what the notes say. Never guess or infer facts about the person.',++ 'Never describe looks, body, clothing, hair, face, pose, age or a guessed gender: no looks, at any time.',++ 'Write at most 400 words and do not add a title.',++ '',++ notes,++ ].join('\n');++ if (!registry || !pool) return notes.split('\n').slice(0, 40).join('\n').slice(0, 4000);++ // The platform's own cheap route, called in-process: no run row, no on_behalf_of and no person attribution,++ // so the notes are not stored again (a run's input would keep their plaintext) and the person's allowance is++ // not spent. A route of its own would be the cheap tier; default.chat is the service's smallest chat pool.++ const route = await registry.resolveRoute('default.chat');++ if (!route || route.disabled) throw new AiError(503, 'memory.profile_failed', 'no route is available to build a profile');++ const exec = await pool.execute(route, 'summarize', {++ system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess, never describe looks or a body, and never add a fact the notes do not state.', cache: true }],++ messages: [{ role: 'user', content: prompt }],++ maxTokens: 400,++ temperature: 0.2,++ timeoutMs: 30000,++ }, { routeKey: route.key, routeVersion: route.version });++ return String((exec.result && exec.result.text) || '');++ }++++ async function profile(req, owner) {++ assertConfigured();++ const scope = validateScope(req.query.scope, 'user');++ requireScope(req, scope);++ const rows = await activeRows(owner, scope);++ const hash = itemsHash(rows.map((r) => r.text_mac));++ const now = clock.now();++ const cached = await q('SELECT * FROM memory_profiles WHERE owner = $1 AND scope = $2').get(owner, scope);++ if (cached) {++ const age = now - Date.parse(cached.built_at);++ const fresh = Number.isFinite(age) && age < PROFILE_INTERVAL_MS;++ if (fresh || cached.items_hash === hash) {++ const text = open(cached.text_enc, aadFor(owner, PROFILE_ID, scope));++ return { profile: { scope, text, items_hash: cached.items_hash, built_at: cached.built_at } };++ }++ }++ if (!rows.length) {++ const builtAt = iso(now);++ const empty = seal('', aadFor(owner, PROFILE_ID, scope));++ await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)++ ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)++ .run(owner, scope, empty, hash, builtAt);++ return { profile: { scope, text: '', items_hash: hash, built_at: builtAt } };++ }++ const items = rows.map((r) => ({ kind: r.kind, text: open(r.text_enc, aadFor(owner, r.id, r.scope)) }));++ const text = String(noLooks(await summarize({ owner, scope, items })) || '').slice(0, 4000);++ const builtAt = iso(now);++ await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)++ ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)++ .run(owner, scope, seal(text, aadFor(owner, PROFILE_ID, scope)), hash, builtAt);++ return { profile: { scope, text, items_hash: hash, built_at: builtAt } };++ }++++ /** An agent's search: active items only, the granted scopes, at most 8, and one memory_reads row with ids only. */++ async function search(req, owner) {++ assertConfigured();++ const raw = req.query.scopes == null || req.query.scopes === '' ? 'user' : String(req.query.scopes);++ const scopes = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))];++ if (!scopes.length) throw new AiError(422, 'input.invalid', 'scopes must name at least one scope');++ for (const s of scopes) {++ validateScope(s);++ requireScope(req, s);++ }++ const searchText = req.query.q == null ? '' : String(req.query.q).slice(0, 500).toLowerCase();++ const rows = await q("SELECT * FROM memory_items WHERE owner = $1 AND status = 'active' AND scope = ANY($2::text[]) ORDER BY updated_at DESC LIMIT 200").all(owner, scopes);++ const items = [];++ for (const row of rows) {++ const view = itemView(row);++ if (!searchText || view.text.toLowerCase().includes(searchText)) items.push(view);++ if (items.length >= MAX_SEARCH) break;++ }++ if (req.principal.kind !== 'user') {++ const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);++ await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')++ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, purpose ? purpose.slice(0, 200) : null, JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));++ }++ return { items, count: items.length };++ }++++ /** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */++ async function exportFor(subject) {++ assertConfigured();++ const owner = `user:${subject}`;++ const items = (await q('SELECT * FROM memory_items WHERE owner = $1 ORDER BY created_at DESC LIMIT 5000').all(owner)).map(itemView);++ const profiles = (await q('SELECT * FROM memory_profiles WHERE owner = $1 ORDER BY built_at DESC').all(owner))++ .map((r) => ({ scope: r.scope, text: open(r.text_enc, aadFor(owner, PROFILE_ID, r.scope)), items_hash: r.items_hash, built_at: r.built_at }));++ const reads = await q('SELECT id, actor, purpose, scopes, item_ids, created_at FROM memory_reads WHERE owner = $1 ORDER BY created_at DESC LIMIT 5000').all(owner);++ return [++ { name: 'memory-items.json', content: items },++ { name: 'memory-profiles.json', content: profiles },++ { name: 'memory-reads.json', content: reads.map((r) => ({ ...r, scopes: JSON.parse(r.scopes || '[]'), item_ids: JSON.parse(r.item_ids || '[]') })) },++ ];++ }++++ return { assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, aadFor, macOf, seal, open };++}++++/**++ * The memory routes. A person's own Network token acts for themselves; a service token needs the capability and++ * names the person with `on_behalf_of`, but it may only search (audited) and import suggestions. Every other route is++ * person-only: a service gets 403 `capability.denied`.++ */++function memoryRouter({ memory, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {++ const r = express.Router();++ function guard(check) {++ return async function memoryGuard(req, res, next) {++ const ctx = req.ov;++ const token = bearer(req);++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a person token or a service token (audience openvibe.ai) is required', ctx });++ let service = { ok: false };++ try { service = await auth.verify(token); } catch (err) { service = { ok: false, code: 'token.invalid', reason: 'invalid token' }; }++ if (service.ok && !isAppClaims(service.claims)) {++ const decision = check(service.claims);++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || 'the capability is not granted', ctx });++ req.principal = { kind: 'service', sub: service.claims.sub, subject: principalSubject(service.claims.sub), cap: service.claims.cap, ns: service.claims.ns || [], jti: service.claims.jti, claims: service.claims };++ return next();++ }++ if (service.ok) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });++ // A person's Network token: the same key and issuer, no principal type. The subject is subject_id (usr_).++ try {++ const claims = await verifyUserToken(token, {++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),++ issuer: config.issuer,++ audience: ['openvibe.network', 'openvibe.ai'],++ now: clock.now(),++ log,++ });++ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');++ if (!subject) throw new AiError(401, 'token.invalid', 'the person token has no usr_ subject');++ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };++ return next();++ } catch (err) {++ return http.sendProblem(res, err.status || 401, err.code || 'token.invalid', { detail: err.detail || 'invalid token', ctx });++ }++ };++ }++ const run = (fn) => async (req, res) => {++ try { res.set('Cache-Control', 'no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }++ };++ /**++ * The person's own routes. A service with the read or write capability may only search (the audited agent read)++ * and import suggestions; it may not browse, profile, patch or delete the person's memory. The guard runs after++ * `guard`, which has set req.principal.++ */++ function personOnly(req, res, next) {++ if (req.principal.kind === 'service') {++ return http.sendProblem(res, 403, 'capability.denied', { detail: "this route is for the person's own token", ctx: req.ov });++ }++ return next();++ }++ r.post('/api/v1/memory/items\\:batch', guard(WRITE_CAP), run(async (req) => await memory.batch(req, ownerOf(req))));++ r.get('/api/v1/memory/items', guard(READ_CAP), personOnly, run(async (req) => await memory.list(req, ownerOf(req))));++ r.get('/api/v1/memory/items/:id', guard(READ_CAP), personOnly, run(async (req) => await memory.get(req, ownerOf(req), req.params.id)));++ r.patch('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.patch(req, ownerOf(req), req.params.id)));++ r.delete('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.remove(req, ownerOf(req), req.params.id)));++ r.delete('/api/v1/memory/items', guard(WRITE_CAP), personOnly, run(async (req) => await memory.removeBySource(req, ownerOf(req))));++ r.get('/api/v1/memory/profile', guard(READ_CAP), personOnly, run(async (req) => await memory.profile(req, ownerOf(req))));++ r.get('/api/v1/memory/search', guard(READ_CAP), run(async (req) => await memory.search(req, ownerOf(req))));++ // The owner is resolved after the guard has set the principal.++ function ownerOf(req) {++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const supplied = body.on_behalf_of != null ? body.on_behalf_of : req.query && req.query.on_behalf_of;++ if (req.principal.kind === 'user') {++ if (supplied != null && supplied !== req.principal.subject.id) throw new AiError(403, 'capability.denied', 'a person token acts only for its own owner');++ return `user:${req.principal.subject.id}`;++ }++ const claimed = req.principal.claims.on_behalf_of;++ if (supplied != null && claimed != null && String(supplied) !== String(claimed)) throw new AiError(403, 'capability.denied', 'on_behalf_of does not match the token');++ const person = claimed != null ? claimed : supplied;++ if (person == null || !SUBJECT_RE.test(String(person))) throw new AiError(422, 'input.invalid', 'on_behalf_of must name a usr_ subject');++ return `user:${person}`;++ }++ return r;++}++++module.exports = { createMemory, memoryRouter, PROFILE_INTERVAL_MS };@@@ -77,6 +77,58 @@ function route(method, p, operation) {(PATHS[p] = PATHS[p] || {})[method] = operation;}++// ── Memory (Memory M2a) ──────────────────────────────────────────────────────────────────────++const memoryItem = ref('MemoryItem');++const memoryBody = ref('MemoryWriteRequest');++const memoryResult = ref('MemoryResult');++const memorySecurity = SERVICE; // the person's own token, or a service token plus on_behalf_of++route('post', '/memory/items:batch', {++ operationId: 'importMemoryItems', summary: 'Import up to 200 memory items', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. Redaction runs again on the server; an item that is only a secret is refused. Dedupe is by text_mac within owner and scope: the same text updates seen_at and adds the source. AI-produced items are suggestions until the person accepts them.',++ requestBody: body(memoryBody),++ responses: { 200: json('The stored (or deduped) items', memoryResult), 422: problem, 503: problem, ...errors([400, 401, 403]) },++});++route('get', '/memory/items', {++ operationId: 'listMemoryItems', summary: 'List the person’s own memory items', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. q searches decrypted text in-process over that owner’s rows only, capped at 2,000.',++ parameters: [query('scope', str()), query('status', str({ enum: ['active', 'suggested', 'archived', 'conflict'] })), query('q', str()), query('on_behalf_of', str())],++ responses: { 200: json('The person’s items', memoryResult), 403: problem, 503: problem, ...errors([401]) },++});++route('delete', '/memory/items', {++ operationId: 'deleteMemorySource', summary: 'Delete everything from one source', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. Delete means gone: the rows are removed and the profiles that used them are rebuilt.',++ parameters: [{ ...query('source', str(), 'The adapter id or service to delete'), required: true }, query('on_behalf_of', str())],++ responses: { 200: json('How many rows were removed', memoryResult), 422: problem, 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/items/{id}', {++ operationId: 'getMemoryItem', summary: 'Read one memory item', tags: ['memory'], security: memorySecurity,++ parameters: [path('id', str()), query('on_behalf_of', str())],++ responses: { 200: json('The item', memoryResult), 404: problem, 503: problem, ...errors([401, 403]) },++});++route('patch', '/memory/items/{id}', {++ operationId: 'updateMemoryItem', summary: 'Change text, kind, scope or status', tags: ['memory'], security: memorySecurity,++ parameters: [path('id', str())],++ requestBody: body(memoryBody),++ responses: { 200: json('The changed item', memoryResult), 404: problem, 422: problem, 503: problem, ...errors([401, 403]) },++});++route('delete', '/memory/items/{id}', {++ operationId: 'deleteMemoryItem', summary: 'Delete one memory item', tags: ['memory'], security: memorySecurity,++ parameters: [path('id', str()), query('on_behalf_of', str())],++ responses: { 200: json('How many rows were removed', memoryResult), 404: problem, 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/profile', {++ operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result passes noLooks.',++ parameters: [query('scope', str()), query('on_behalf_of', str())],++ responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/search', {++ operationId: 'searchMemoryForAgent', summary: 'An agent reads active memory in its granted scopes', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read plus on_behalf_of. Active items only, at most 8, in the scopes the token’s grant names (user always; project:* only when granted). The read is audited in memory_reads with item ids, never text.',++ parameters: [query('q', str()), query('scopes', str(), 'Comma-separated scopes; default user'), query('purpose', str(), 'The task or run the person sees'), query('on_behalf_of', str())],++ responses: { 200: json('At most 8 active items', memoryResult), 403: problem, 503: problem, ...errors([401]) },++});++// ── Runs ──────────────────────────────────────────────────────────────────────────────────────route('post', '/runs', {operationId: 'createRun', summary: 'Start a workflow run (201 finished, 202 queued or running, 200 replay)', tags: ['runs'], security: SERVICE,@@@ -234,6 +286,29 @@ function openapi() {snippet: nullable('string'), content_hash: nullable('string'), trust: { type: 'object' }, provenance: { type: 'object' },attached_by: str(), created_at: str({ format: 'date-time' }),} },++ MemoryItem: { type: 'object', required: ['id', 'owner', 'scope', 'kind', 'text', 'source', 'status', 'confidence', 'created_at', 'updated_at'], properties: {++ id: str({ pattern: '^mem_[0-9A-HJKMNP-TV-Z]{26}$' }), owner: str(), scope: str(),++ kind: str({ enum: ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'] }),++ text: str(), source: str(), source_ref: nullable('object'),++ status: str({ enum: ['active', 'suggested', 'archived', 'conflict'] }), confidence: { type: 'number' },++ seen_at: str({ format: 'date-time' }), created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),++ machine: nullable('string'), no_looks: { type: 'boolean' },++ } },++ MemoryProfile: { type: 'object', properties: {++ scope: str(), text: str(), items_hash: str({ pattern: '^[0-9a-f]{64}$' }), built_at: str({ format: 'date-time' }),++ } },++ MemoryResult: { type: 'object', properties: {++ items: arrayOf(ref('MemoryItem')), item: ref('MemoryItem'), profile: ref('MemoryProfile'),++ deleted: integer({ minimum: 0 }), count: integer({ minimum: 0 }),++ } },++ MemoryWriteRequest: { type: 'object', properties: {++ items: arrayOf(ref('MemoryItemWrite')), on_behalf_of: str(), text: str(), kind: str(), scope: str(), status: str(), source: str(),++ } },++ MemoryItemWrite: { type: 'object', required: ['text'], properties: {++ text: str(), kind: str(), scope: str(), source: str(), source_ref: nullable('object'), on_behalf_of: str(),++ status: str({ enum: ['active', 'suggested', 'archived', 'conflict'] }), confidence: { type: 'number' },++ machine: nullable('string'), no_looks: { type: 'boolean' }, seen_at: str({ format: 'date-time' }),++ } },ActionItem: { type: 'object', required: ['service', 'id', 'kind', 'title', 'bucket', 'state'], properties: {service: { const: 'ai' }, id: str(), kind: { const: 'ai_run' }, title: str(), summary: nullable('string'),bucket: { type: 'string', enum: ['active', 'done', 'failed'] }, state: str(), agent: nullable('string'),nnew file mode 100644@@@ -0,0 +1,357 @@++'use strict';++/**++ * Memory M2a: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner scope,++ * the agent search (active only, at most 8, audited), the profile cache, delete, the 503 without a key, account data++ * and the no-plaintext-in-logs rule.++ */++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { uncovered } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, request, suite, token, privateKey, ISSUER } = require('./helpers');++const { TABLES } = require('../server/account-data');++const { createMemory } = require('../server/memory');++++const t = suite('memory');++const KEY = 'a'.repeat(64);++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++const SECRET = `whsec_${'memory'.repeat(5)}`;++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++++/** A person's Network access token: no actor_type, the subject in subject_id. */++function userToken(subject, { aud = ['openvibe.network'] } = {}) {++ const now = Math.floor(Date.now() / 1000);++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud, iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++const serviceWrite = (extra = {}) => token('codes', ['ai.memory.write'], { extra });++const serviceRead = (extra = {}) => token('codes', ['ai.memory.read'], { extra });++const item = (text, extra = {}) => ({ text, ...extra });++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_ai', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++t.test('without AI_MEMORY_KEY every route answers 503 memory.unavailable', async () => {++ const h = await boot();++ try {++ const r = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite(), body: { items: [item('a note')], on_behalf_of: A } });++ assert.deepStrictEqual([r.status, r.body.code], [503, 'memory.unavailable']);++ const g = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(A) });++ assert.deepStrictEqual([g.status, g.body.code], [503, 'memory.unavailable']);++ } finally { await h.stop(); }++});++++t.test('sealing is a round trip and the AAD binds owner, id and scope', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const aad = h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M01', 'project:demo');++ const sealed = h.memory.seal('the note', aad);++ assert.match(sealed, /^m1:/);++ assert.equal(h.memory.open(sealed, aad), 'the note');++ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${B}`, 'mem_01JZ0000000000000000000M01', 'project:demo')), /does not open/);++ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M01', 'user')), /does not open/);++ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M02', 'project:demo')), /does not open/);++ } finally { await h.stop(); }++});++++t.test('a batch stores sealed text, dedupes by MAC and redacts a secret again on the server', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const first = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [++ item('Prefers short answers.', { source: 'cursor', scope: 'user' }),++ item(`the deploy token is ${'ghp_' + 'A'.repeat(36)}`, { source: 'claude-code' }),++ ] } });++ assert.deepStrictEqual([first.status, first.body.count, first.body.created, first.body.deduped], [200, 2, 2, 0]);++ assert.equal(first.body.items[1].text.includes('ghp_'), false);++ assert.ok(first.body.items[1].text.includes('[redacted]'));++ assert.ok(!('text_enc' in first.body.items[0]) && !('text_mac' in first.body.items[0]), 'sealed fields never leave the module');++++ const row = await h.db.prepare('SELECT * FROM memory_items WHERE owner = ? ORDER BY created_at LIMIT 1').get(`user:${A}`);++ assert.match(row.text_enc, /^m1:/);++ assert.match(row.text_mac, /^[0-9a-f]{64}$/);++ assert.ok(!('text' in row), 'there is no plaintext column');++++ const again = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('Prefers short answers.', { source: 'claude-code' })] } });++ assert.deepStrictEqual([again.status, again.body.created, again.body.deduped], [200, 0, 1]);++ const rows = await h.db.prepare('SELECT source FROM memory_items WHERE owner = ? AND scope = ?').all(`user:${A}`, 'user');++ assert.equal(rows.length, 2);++ assert.match(rows.find((r) => r.source.includes('cursor')).source, /cursor,claude-code|claude-code,cursor/);++++ const refused = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('sk-' + 'proj-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8')] } });++ assert.deepStrictEqual([refused.status, refused.body.code], [422, 'memory.secret_only']);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${A}`])), 2);++ } finally { await h.stop(); }++});++++t.test('one person cannot read, change or delete another person\'s item, and every query is owner-scoped', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('A private note')] } });++ const id = made.body.items[0].id;++ const asB = userToken(B);++ assert.equal((await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: asB })).status, 404);++ assert.equal((await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: asB, body: { text: 'hijacked' } })).status, 404);++ assert.equal((await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: asB })).status, 404);++ const list = await request(h.base, 'GET', '/api/v1/memory/items', { tok: asB });++ assert.deepStrictEqual([list.status, list.body.items.length], [200, 0]);++++ const dir = path.join(__dirname, '..', 'server', 'memory');++ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));++ const problems = [];++ for (const f of files) {++ const src = fs.readFileSync(path.join(dir, f), 'utf8');++ for (const m of src.matchAll(/(['"`])((?:(?!\1)[\s\S])*?(?:memory_items|memory_profiles|memory_reads)[\s\S]*?)\1/g)) {++ const sql = m[2].trim();++ if (!/^(SELECT|UPDATE|DELETE)\b/i.test(sql)) continue;++ if (!/owner = \$/.test(sql)) problems.push(`${f}: ${sql.split('\n')[0]}`);++ }++ }++ assert.deepStrictEqual(problems, [], `a memory query is not owner-scoped: ${problems.join(' | ')}`);++ } finally { await h.stop(); }++});++++t.test('an agent sees active items only, at most 8, in its granted scopes, and the read is audited with ids only', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const items = [];++ for (let i = 0; i < 10; i++) items.push(item(`active note ${i}`, { scope: 'user' }));++ items.push(item('suggested note', { scope: 'user', status: 'suggested' }));++ items.push(item('archived note', { scope: 'user', status: 'archived' }));++ items.push(item('project note', { scope: 'project:demo' }));++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items } });++ assert.equal(made.status, 200);++++ const grant = { memory_scopes: ['user', 'project:demo'] };++ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=note&scopes=user&purpose=task-9&on_behalf_of=${A}`, { tok: serviceRead(grant) });++ assert.equal(search.status, 200);++ assert.equal(search.body.items.length, 8);++ assert.ok(search.body.items.every((i) => i.status === 'active' && i.scope === 'user'));++ assert.ok(!search.text.includes('suggested note') && !search.text.includes('archived note'));++++ const audit = await h.db.prepare('SELECT * FROM memory_reads WHERE owner = ?').get(`user:${A}`);++ assert.equal(audit.actor, 'svc:codes');++ assert.equal(audit.purpose, 'task-9');++ const ids = JSON.parse(audit.item_ids);++ assert.equal(ids.length, 8);++ assert.ok(ids.every((id) => id.startsWith('mem_')));++ assert.ok(!audit.item_ids.includes('active note'), 'the audit holds ids, never text');++++ const project = await request(h.base, 'GET', `/api/v1/memory/search?q=project&scopes=project:demo&on_behalf_of=${A}`, { tok: serviceRead(grant) });++ assert.deepStrictEqual([project.status, project.body.items.length], [200, 1]);++ const denied = await request(h.base, 'GET', `/api/v1/memory/search?q=project&scopes=project:demo&on_behalf_of=${A}`, { tok: serviceRead() });++ assert.deepStrictEqual([denied.status, denied.body.code], [403, 'memory.scope_denied']);++++ const mismatch = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite({ on_behalf_of: A }), body: { items: [item('x')], on_behalf_of: B } });++ assert.deepStrictEqual([mismatch.status, mismatch.body.code], [403, 'capability.denied']);++ } finally { await h.stop(); }++});++++t.test('a service may only search and suggest: every other memory route is person-only', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item("the person's active note")] } });++ const id = made.body.items[0].id;++ const read = serviceRead({ memory_scopes: ['user'] });++ const denied = (r) => {++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied']);++ assert.match(r.body.detail, /person's own token/);++ };++ denied(await request(h.base, 'GET', `/api/v1/memory/items?on_behalf_of=${A}`, { tok: read }));++ denied(await request(h.base, 'GET', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: read }));++ denied(await request(h.base, 'GET', `/api/v1/memory/profile?scope=user&on_behalf_of=${A}`, { tok: read }));++ denied(await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: serviceWrite(), body: { text: 'hijacked', on_behalf_of: A } }));++ denied(await request(h.base, 'DELETE', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: serviceWrite() }));++ denied(await request(h.base, 'DELETE', `/api/v1/memory/items?source=person&on_behalf_of=${A}`, { tok: serviceWrite() }));++ // None of the refusals touched the person's row.++ const still = await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });++ assert.deepStrictEqual([still.status, still.body.item.text, still.body.item.status], [200, "the person's active note", 'active']);++++ // A service's write is a suggestion under its own name, whatever the body says.++ const suggested = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite(), body: { items: [++ item('a service suggestion', { status: 'active', source: 'person', confidence: 0.9 }),++ ], on_behalf_of: A } });++ assert.deepStrictEqual([suggested.status, suggested.body.items[0].status, suggested.body.items[0].source], [200, 'suggested', 'codes']);++ assert.ok(suggested.body.items[0].confidence <= 0.5, `confidence ${suggested.body.items[0].confidence} > 0.5`);++ const row = await h.db.prepare('SELECT status, source, confidence FROM memory_items WHERE owner = ? AND id = ?').get(`user:${A}`, suggested.body.items[0].id);++ assert.deepStrictEqual([row.status, row.source, Number(row.confidence)], ['suggested', 'codes', 0.5]);++++ // Search still works for a service, and it is audited.++ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&on_behalf_of=${A}`, { tok: read });++ assert.deepStrictEqual([search.status, search.body.items.length], [200, 1]);++ const audit = await h.db.prepare('SELECT actor, item_ids FROM memory_reads WHERE owner = ?').get(`user:${A}`);++ assert.equal(audit.actor, 'svc:codes');++ assert.ok(!audit.item_ids.includes('active note'));++ } finally { await h.stop(); }++});++++t.test('a person token still writes active items, and still reads, changes and deletes them', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [++ item('a person item', { status: 'active', source: 'claude-code', confidence: 1 }),++ ] } });++ assert.deepStrictEqual([made.status, made.body.items[0].status, made.body.items[0].source, made.body.items[0].confidence], [200, 'active', 'claude-code', 1]);++ const id = made.body.items[0].id;++ const list = await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });++ assert.deepStrictEqual([list.body.items.length, (await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who })).status], [1, 200]);++ const patched = await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { text: 'a person edit' } });++ assert.deepStrictEqual([patched.status, patched.body.item.text], [200, 'a person edit']);++ assert.equal((await request(h.base, 'GET', '/api/v1/memory/profile?scope=project:empty', { tok: who })).status, 200);++ const removed = await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });++ assert.deepStrictEqual([removed.status, removed.body.deleted], [200, 1]);++ } finally { await h.stop(); }++});++++t.test('delete removes the row and the profile built from it', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('a note to remove')] } });++ const id = made.body.items[0].id;++ // No items: the profile route writes an empty cache row without spending a model call.++ const empty = await request(h.base, 'GET', '/api/v1/memory/profile?scope=project:gone', { tok: who });++ assert.equal(empty.status, 200);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'project:gone'])), 1);++ const prof = await request(h.base, 'GET', '/api/v1/memory/profile?scope=user', { tok: who });++ assert.equal(prof.status, 200);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'user'])), 1);++ const del = await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });++ assert.deepStrictEqual([del.status, del.body.deleted], [200, 1]);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1 AND id = $2', [`user:${A}`, id])), 0);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'user'])), 0);++ const gone = await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });++ assert.deepStrictEqual([gone.status, gone.body.code], [404, 'memory.not_found']);++ } finally { await h.stop(); }++});++++t.test('a scope change re-seals the text, and deleting one source leaves the others', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [++ item('a cursor note', { source: 'cursor' }),++ item('a claude note', { source: 'claude-code' }),++ ] } });++ const id = made.body.items[0].id;++ const patched = await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { scope: 'project:demo', text: 'moved note' } });++ assert.deepStrictEqual([patched.status, patched.body.item.scope, patched.body.item.text], [200, 'project:demo', 'moved note']);++ const row = await h.db.prepare('SELECT * FROM memory_items WHERE owner = ? AND id = ?').get(`user:${A}`, id);++ assert.equal(h.memory.open(row.text_enc, h.memory.aadFor(`user:${A}`, id, 'project:demo')), 'moved note');++ assert.throws(() => h.memory.open(row.text_enc, h.memory.aadFor(`user:${A}`, id, 'user')), /does not open/);++ const searched = await request(h.base, 'GET', `/api/v1/memory/items?q=moved`, { tok: who });++ assert.deepStrictEqual([searched.status, searched.body.items.length], [200, 1]);++ const removed = await request(h.base, 'DELETE', '/api/v1/memory/items?source=cursor', { tok: who });++ assert.deepStrictEqual([removed.status, removed.body.deleted], [200, 1]);++ const left = await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });++ assert.deepStrictEqual(left.body.items.map((i) => i.source), ['claude-code']);++ } finally { await h.stop(); }++});++++t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and passes noLooks', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ let now = Date.parse('2026-10-30T09:00:00.000Z');++ let calls = 0;++ const m = createMemory({ db: h.db, config: h.config, clock: { now: () => now }, log: h.log || console,++ buildProfile: async ({ items }) => { calls++; return `A shirtless man likes ${items.length} notes. He is a person.`; } });++ const req = { principal: { kind: 'user', subject: { type: 'user', id: A } }, query: { scope: 'user' }, body: {}, ov: {} };++ const owner = `user:${A}`;++ await m.batch({ principal: req.principal, body: { items: [item('one note'), item('two notes')] } }, owner);++ const first = await m.profile(req, owner);++ assert.equal(calls, 1);++ assert.ok(!first.profile.text.includes('shirtless') && !first.profile.text.includes('man'), `noLooks did not run: ${first.profile.text}`);++ assert.equal(first.profile.text.includes('[redacted]'), false);++ const second = await m.profile(req, owner);++ assert.equal(calls, 1, 'the same items_hash is served from the cache');++ assert.equal(second.profile.built_at, first.profile.built_at);++++ await m.batch({ principal: req.principal, body: { items: [item('a third note')] } }, owner);++ const withinHour = await m.profile(req, owner);++ assert.equal(calls, 1, 'a changed hash within the hour waits');++ assert.equal(withinHour.profile.built_at, first.profile.built_at);++++ now += 61 * 60 * 1000;++ const rebuilt = await m.profile(req, owner);++ assert.equal(calls, 2);++ assert.notEqual(rebuilt.profile.items_hash, first.profile.items_hash);++ assert.notEqual(rebuilt.profile.built_at, first.profile.built_at);++ } finally { await h.stop(); }++});++++t.test('account data: the table guard passes, the export is decrypted, and a deletion erases the person\'s memory', async () => {++ const stub = await startNetworkStub();++ const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });++ try {++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });++ assert.deepStrictEqual(missing, []);++ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('Ada exports this note')] } });++ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(B), body: { items: [item('Bo keeps this note')] } });++ const deliver = async (event) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the memory export part was pushed');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.ok(files['memory-items.json'].some((i) => i.text === 'Ada exports this note'));++ assert.ok(!JSON.stringify(part.body).includes('Bo keeps this note'), 'nobody else\'s memory');++ assert.ok(!JSON.stringify(part.body).includes('m1:'), 'the export is decrypted, not sealed');++++ const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${A}`])), 0);++ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${B}`])), 1);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('no item text reaches the logger during a full flow', async () => {++ const lines = [];++ const log = { log: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };++ const h = await boot({ env: { AI_MEMORY_KEY: KEY }, log });++ try {++ const who = userToken(A);++ const marker = 'a-very-distinctive-note-that-must-not-be-logged';++ const secret = ['correct', 'horse', 'battery', 'staple'].join(' ');++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item(marker), item(`password: "${secret}"`)] } });++ assert.equal(made.status, 200);++ const id = made.body.items[0].id;++ await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });++ await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });++ await request(h.base, 'GET', `/api/v1/memory/search?q=${encodeURIComponent(marker)}&scopes=user&on_behalf_of=${A}`, { tok: serviceRead({ memory_scopes: ['user'] }) });++ await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { text: `${marker} edited` } });++ await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });++ const all = lines.join('\n');++ assert.ok(!all.includes(marker), `the logger saw the item text: ${all}`);++ assert.ok(!all.includes(secret), 'the logger saw the secret');++ assert.ok(!all.includes(['correct', 'horse'].join(' ')), 'the logger saw part of the secret');++ } finally { await h.stop(); }++});++++t.run();@@@ -20,7 +20,7 @@ function mountOf(layer) {}/** ":param" as OpenAPI writes it, "{param}". */--const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');++const templated = (p) => String(p).replace(/\\:/g, '\u0000').replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}').replace(/\u0000/g, ':');/** Every "METHOD /path" the app answers, routers included. */function mounted(app) {
@@ -84,6 +84,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -84,6 +84,13 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# stored key unreadable: the storing service then has to send them again.
# stored key unreadable: the storing service then has to send them again.
#AI_CREDENTIALS_KEY=
#AI_CREDENTIALS_KEY=
+# ── Memory (server/memory/, Memory M2a) ──
+# 64 hex characters (openssl rand -hex 32). Memory item text and source references are AES-256-GCM sealed with it;
+# the dedupe MAC is HKDF-SHA256 (info "memory-mac") of the same key. Unset turns /api/v1/memory off (503
+# memory.unavailable). A key of its own, so credentials and memory rotate separately. Do not set it in production until
+# the Memory M2a review; changing it makes existing items unreadable.
+#AI_MEMORY_KEY=
+
# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_pricing_json,
# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_pricing_json,
# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day).
# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day).
@@ -49,15 +49,16 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -49,15 +49,16 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
and the local model server `openvibe-llm.service` on this host
and the local model server `openvibe-llm.service` on this host
-- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.74.0 (service tokens, events outbox),
- `openvibe-shared` v2.3.1, pinned by release tarball
+- `openvibe-contracts` v0.252.0, `openvibe-sdk` v0.75.0 (service tokens, events outbox, redact),
+ `openvibe-shared` v4.53.0, pinned by release tarball
## Capabilities
## Capabilities
Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under
Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),
-`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; public,
+`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
+`/api/v1/memory/*`) and `ai.app.run` (developer apps, ADR-014; public,
registered since openvibe-contracts 0.109.0). A service token's `ns`
registered since openvibe-contracts 0.109.0). A service token's `ns`
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
@@ -111,6 +112,8 @@ idempotent replay):
@@ -111,6 +112,8 @@ idempotent replay):
| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
| `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) |
| `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) |
+| `POST /api/v1/memory/items:batch`, `GET/PATCH/DELETE /api/v1/memory/items[/:id]`, `DELETE /api/v1/memory/items?source=`, `GET /api/v1/memory/profile` | `ai.memory.read` / `ai.memory.write` (the person's own token, or a service acting `on_behalf_of` them) |
+| `GET /api/v1/memory/search?q=&scopes=` — active items only, at most 8, in the grant's scopes, audited | `ai.memory.read` plus `on_behalf_of` |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` |
| `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
@@ -203,6 +206,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
@@ -203,6 +206,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
- Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is
- Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is
a small local model without tools, so a coding agent's loop needs its project's own provider.
a small local model without tools, so a coding agent's loop needs its project's own provider.
+## Memory
+
+Memory M2a (spec `research-2026-10-30/memory-m2-spec.md`): a person's own notes, sealed with `AI_MEMORY_KEY`
+(AES-256-GCM, AAD `memory|<owner>|<id>|<scope>`, `m1:`), deduped by an HMAC of the normalized text (HKDF-SHA256, info
+`memory-mac`), and scoped by `user:<usr_…>` on every query. The server redacts every item again with
+`openvibe-sdk/redact` (the Node's shapes and corpus) and refuses an item that is only a secret; what an agent writes is
+a suggestion, and every item a service writes passes `noLooks`. An agent's search is active-only, at most 8 items, in
+the scopes its token's grant names (`user` always; `project:*` when the grant lists it), and leaves a `memory_reads`
+row with ids, never text. The profile cache rebuilds only when its `items_hash` moves, at most once an hour, in-process
+on the service's own chat route (no run row, no person allowance). The account export decrypts the person's items for
+their own export; a deletion removes the rows. Unset `AI_MEMORY_KEY` turns every memory route into 503
+`memory.unavailable`; no default key exists.
+
## Anthropic-compatible endpoint
## Anthropic-compatible endpoint
`POST /v1/messages` and `GET /v1/models` (`server/api/messages.js`), so a client built for Anthropic's Messages API
`POST /v1/messages` and `GET /v1/models` (`server/api/messages.js`), so a client built for Anthropic's Messages API
@@ -641,6 +657,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -641,6 +657,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
(a four-character hint only). No response, log or console page carries a secret value.
(a four-character hint only). No response, log or console page carries a secret value.
+- **Memory.** Item text is sealed with `AI_MEMORY_KEY` and bound to its owner, id and scope; plaintext exists only
+ inside a request, and the profile builder calls the provider in-process so the notes are not written to a run.
- **Private data.** The request log keeps hashes, not prompts; raw prompts and inline images are not
- **Private data.** The request log keeps hashes, not prompts; raw prompts and inline images are not
kept; the cache never crosses requester, actor, target or attribution; `history: false` keeps
kept; the cache never crosses requester, actor, target or attribution; `history: false` keeps
neither input nor cache entry.
neither input nor cache entry.
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+-- phase: expand
+-- OpenVibe Memory M2a (spec research-2026-10-30/memory-m2-spec.md): the person's memory store, one row per item,
+-- sealed at rest with AI_MEMORY_KEY. `owner` is `user:usr_…`; every query is scoped by it. `text_enc` and
+-- `source_ref_enc` are `m1:` AES-256-GCM (AAD memory|<owner>|<id>|<scope>); `text_mac` is HMAC-SHA256 of the
+-- normalized text, for dedupe without reading plaintext. `memory_profiles` is a cache keyed by owner and scope;
+-- `memory_reads` is the audit of what an agent read (item ids, never text).
+CREATE TABLE IF NOT EXISTS memory_items (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- preference | fact | project_rule | procedure | decision | lesson
+ text_enc text COLLATE "C" NOT NULL, -- m1:<iv>:<tag>:<body>
+ text_mac text COLLATE "C" NOT NULL, -- HMAC-SHA256 of the normalized text
+ source text COLLATE "C" NOT NULL, -- adapter id or service
+ source_ref_enc text COLLATE "C", -- sealed { path, heading }
+ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | suggested | archived | conflict
+ confidence double precision NOT NULL DEFAULT 1,
+ seen_at text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ machine text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS memory_items_owner_scope_status ON memory_items (owner, scope, status);
+CREATE INDEX IF NOT EXISTS memory_items_owner_text_mac ON memory_items (owner, text_mac);
+
+CREATE TABLE IF NOT EXISTS memory_profiles (
+ owner text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" NOT NULL,
+ text_enc text COLLATE "C" NOT NULL,
+ items_hash text COLLATE "C" NOT NULL,
+ built_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (owner, scope)
+);
+
+CREATE TABLE IF NOT EXISTS memory_reads (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ actor text COLLATE "C" NOT NULL, -- svc:codes, …
+ purpose text COLLATE "C", -- the task or run the person sees
+ scopes text COLLATE "C" NOT NULL DEFAULT '[]', -- JSON array of the scopes searched
+ item_ids text COLLATE "C" NOT NULL DEFAULT '[]', -- JSON array; never text
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX IF NOT EXISTS memory_reads_owner_created ON memory_reads (owner, created_at);
@@ -13,8 +13,8 @@
@@ -13,8 +13,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1540,9 +1540,9 @@
@@ -1540,9 +1540,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.241.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
- "integrity": "sha512-FRTfO31tBk2znR/uvDM1PuLQ1mXkBSuJq/bTbN4wcYCMWXdOrbRprGMFJfuAIIp/7dfSnjxo0A0yiYk7g/9nEQ==",
+ "version": "0.252.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
+ "integrity": "sha512-+b51PEN0xTiPlHtkrBpLq0cGP4bCZD2Irg0lAqm7CxA6QdIe2NFSq/kGsF6MmYXlGxIrOoe5Nw0u72xUDjgQSg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.74.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
+ "version": "0.75.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
+ "integrity": "sha512-XNuWLLMPHmQFQUS1abjo0As7bRM+bpr4K6EQAHX7CIMHAbkRJK0gF16yfRuCJG/G59oK+I3I19yhDDBYzL/2zw==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -25,8 +25,8 @@
@@ -25,8 +25,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.53.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -28,6 +28,11 @@ const TABLES = [
@@ -28,6 +28,11 @@ const TABLES = [
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
+ // Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
+ // never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
+ { table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
+ { table: 'memory_profiles', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
+ { table: 'memory_reads', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
@@ -58,8 +63,17 @@ async function extraErase(t, subjects, counts) {
@@ -58,8 +63,17 @@ async function extraErase(t, subjects, counts) {
counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));
}
}
-function create({ db, log = console } = {}) {
- return createAccountData({ db, service: 'ai', tables: TABLES, extraExport, extraErase, log });
+function create({ db, log = console, memory = null } = {}) {
+ // Memory text is sealed; the person's own export gets it decrypted from the module that holds the key. Without the
+ // key the rest of the account data still exports, and the memory file says it is unavailable (never any text).
+ const exportAll = async (handle, subject) => {
+ const files = await extraExport(handle, subject);
+ if (!memory) return files;
+ try { files.push(...(await memory.exportFor(subject))); }
+ catch (err) { log.warn(`[account-data] memory export unavailable: ${err.code || err.message}`); files.push({ name: 'memory-items.json', content: { unavailable: 'AI_MEMORY_KEY is not configured on this service' } }); }
+ return files;
+ };
+ return createAccountData({ db, service: 'ai', tables: TABLES, extraExport: exportAll, extraErase, log });
}
}
module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
const home = require('./home');
const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -109,6 +109,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -109,6 +109,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
app.use(require('./api/messages').messagesRouter({ runs, auth, config, log }));
app.use(require('./api/messages').messagesRouter({ runs, auth, config, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ if (memory) app.use(require('./memory').memoryRouter({ memory, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
@@ -58,6 +58,8 @@ function load(env = process.env) {
@@ -58,6 +58,8 @@ function load(env = process.env) {
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
+ // The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.
+ memoryKey: String(env.AI_MEMORY_KEY || '').trim(),
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
audience: 'openvibe.ai',
audience: 'openvibe.ai',
@@ -74,14 +74,16 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -74,14 +74,16 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).
// NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).
const keys = createNetworkKeys({ config });
const keys = createNetworkKeys({ config });
const auth = createAuth({ config, log });
const auth = createAuth({ config, log });
+ // The person's memory (Memory M2a): sealed item text, the profile cache and the agent-read audit.
+ const memory = require('./memory').createMemory({ db, config, clock, log, registry, pool });
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
- const accountData = accountDataLib.create({ db, log });
+ const accountData = accountDataLib.create({ db, log, memory });
const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({
const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({
networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,
networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets, memory });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -144,7 +146,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -144,7 +146,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, close };
}
}
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,567 @@
@@ -0,0 +1,567 @@
+'use strict';
+/**
+ * OpenVibe Memory M2a: the person's memory store (spec research-2026-10-30/memory-m2-spec.md).
+ *
+ * The text belongs to one person. It is sealed with AI_MEMORY_KEY (AES-256-GCM, `m1:` + iv + tag + body) and bound
+ * to owner, item id and scope as additional data, so a row moved to another person or scope does not decrypt. The
+ * dedupe MAC is HMAC-SHA256 of the normalized text, keyed from the same secret through HKDF-SHA256 (info
+ * `memory-mac`), so dedupe never needs plaintext. Plaintext exists only while one request runs: it is never logged,
+ * never in an error message, never in a span or an event payload.
+ *
+ * The API:
+ * POST /api/v1/memory/items:batch import up to 200 items (person token, or a service acting on_behalf_of)
+ * GET /api/v1/memory/items the person's own items (?scope=&status=&q=; q is in-process, max 2,000 rows)
+ * GET /api/v1/memory/items/:id
+ * PATCH /api/v1/memory/items/:id text, kind, scope or status
+ * DELETE /api/v1/memory/items/:id
+ * DELETE /api/v1/memory/items?source= everything from one source
+ * GET /api/v1/memory/profile?scope= the cached profile, rebuilt by the cheap tier only when its items_hash moved,
+ * at most once an hour per owner and scope
+ * GET /api/v1/memory/search?q=&scopes= for agents (ai.memory.read + on_behalf_of): active items only, in the
+ * granted scopes, at most 8, audited in memory_reads with ids and never text
+ *
+ * Every SQL query that reads or changes a memory table is scoped by `owner = $…` (test/memory.test.js greps for it).
+ * The account export decrypts the person's own items for their own export; deletion removes the rows.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { ids, http, capabilities } = require('openvibe-contracts');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+const { redact, onlySecret } = require('openvibe-sdk/redact');
+const { noLooks } = require('openvibe-sdk/no-looks');
+const { AiError, iso, sha256 } = require('../util');
+const { bearer, isAppClaims, principalSubject } = require('../auth');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SCOPE_RE = /^(user|project:[a-z0-9][a-z0-9._-]{0,63})$/;
+const SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
+const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];
+const STATUSES = ['active', 'suggested', 'archived', 'conflict'];
+/** Items from these sources were produced by an AI, so they are suggestions and pass noLooks. */
+const AI_SOURCES = new Set(['actor', 'vibe', 'know']);
+const PROFILE_INTERVAL_MS = 60 * 60 * 1000;
+const PROFILE_ID = 'profile';
+const MAX_BATCH = 200;
+const MAX_LIST = 2000;
+const MAX_SEARCH = 8;
+
+const READ_CAP = (claims) => capabilities.check(claims, 'ai.memory.read');
+const WRITE_CAP = (claims) => capabilities.check(claims, 'ai.memory.write');
+
+function unauthorized(detail) { return new AiError(401, 'token.invalid', detail || 'a Network token (audience openvibe.ai) or a person token is required'); }
+
+/** The grant a service token names for an agent's memory search: `user` always, plus the exact scopes it lists. */
+function grantedScopes(claims) {
+ const out = new Set(['user']);
+ const list = Array.isArray(claims.memory_scopes) ? claims.memory_scopes : Array.isArray(claims.scopes) ? claims.scopes : [];
+ for (const s of list) if (typeof s === 'string' && SCOPE_RE.test(s)) out.add(s);
+ const project = claims.project_slug || claims.project;
+ if (typeof project === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(project)) out.add(`project:${project}`);
+ // A run that names its project in its namespace grant (project:demo or project.demo) may read that project's scope.
+ for (const n of Array.isArray(claims.ns) ? claims.ns : []) {
+ const m = /^project[:.]([a-z0-9][a-z0-9._-]{0,63})$/.exec(String(n));
+ if (m) out.add(`project:${m[1]}`);
+ }
+ return out;
+}
+
+function createMemory({ db, config, clock = { now: () => Date.now() }, log = console, registry = null, pool = null, buildProfile = null }) {
+ let macCache = null;
+ let macCacheFor = null;
+
+ /** The 64-hex key, or 503: no default key is ever invented. */
+ function secretKey() {
+ const hex = String(config.memoryKey || '');
+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'memory.unavailable', 'memory is not configured on this service (AI_MEMORY_KEY)');
+ return Buffer.from(hex, 'hex');
+ }
+ function assertConfigured() { secretKey(); }
+ function macKey() {
+ const hex = String(config.memoryKey || '');
+ if (macCacheFor !== hex || !macCache) {
+ macCache = Buffer.from(crypto.hkdfSync('sha256', secretKey(), Buffer.alloc(0), Buffer.from('memory-mac'), 32));
+ macCacheFor = hex;
+ }
+ return macCache;
+ }
+
+ /** `memory|<owner>|<id>|<scope>`: a row moved to another owner or scope does not open. */
+ function aadFor(owner, id, scope) { return `memory|${owner}|${id}|${scope}`; }
+
+ function seal(plain, aad) {
+ const iv = crypto.randomBytes(12);
+ const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);
+ c.setAAD(Buffer.from(aad));
+ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
+ return `m1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;
+ }
+
+ /** Opens a sealed value; every failure is the same, and no plaintext or ciphertext goes into the error. */
+ function open(sealed, aad) {
+ const parts = String(sealed || '').split(':');
+ if (parts.length !== 4 || parts[0] !== 'm1') throw new AiError(500, 'memory.unreadable', 'a memory row does not open');
+ try {
+ const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), Buffer.from(parts[1], 'base64'));
+ d.setAAD(Buffer.from(aad));
+ d.setAuthTag(Buffer.from(parts[2], 'base64'));
+ return Buffer.concat([d.update(Buffer.from(parts[3], 'base64')), d.final()]).toString('utf8');
+ } catch { throw new AiError(500, 'memory.unreadable', 'a memory row does not open'); }
+ }
+
+ /**
+ * The statements below write PostgreSQL's `$n` placeholders, which is what the spec's owner-scope grep looks for;
+ * the SDK's db layer compiles SQLite-style `?` placeholders, so this adapter maps the written `$n` to `?` and
+ * passes the arguments in the order the statement names them.
+ */
+ function q(sql) {
+ const order = [];
+ let text = '';
+ let last = 0;
+ for (const m of sql.matchAll(/\$(\d+)/g)) {
+ text += sql.slice(last, m.index) + '?';
+ order.push(Number(m[1]) - 1);
+ last = m.index + m[0].length;
+ }
+ text += sql.slice(last);
+ const stmt = db.prepare(text);
+ const args = (values) => order.map((i) => values[i]);
+ return {
+ get: (...values) => stmt.get(...args(values)),
+ all: (...values) => stmt.all(...args(values)),
+ run: (...values) => stmt.run(...args(values)),
+ };
+ }
+
+ const normalize = (text) => String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ const macOf = (text) => crypto.createHmac('sha256', macKey()).update(normalize(text)).digest('hex');
+
+ function validateScope(scope, fallback = 'user') {
+ const s = String(scope == null || scope === '' ? fallback : scope);
+ if (!SCOPE_RE.test(s)) throw new AiError(422, 'input.invalid', 'scope must be `user` or `project:<slug>` (never an absolute path)');
+ return s;
+ }
+ function validateKind(kind, fallback = 'fact') {
+ const k = String(kind == null || kind === '' ? fallback : kind);
+ if (!KINDS.includes(k)) throw new AiError(422, 'input.invalid', `kind must be one of ${KINDS.join(', ')}`);
+ return k;
+ }
+ function validateStatus(status, fallback = 'active') {
+ const s = String(status == null || status === '' ? fallback : status);
+ if (!STATUSES.includes(s)) throw new AiError(422, 'input.invalid', `status must be one of ${STATUSES.join(', ')}`);
+ return s;
+ }
+ function validateSource(source) {
+ const s = String(source == null ? '' : source).trim();
+ if (!SOURCE_RE.test(s)) throw new AiError(422, 'input.invalid', 'source must be an adapter id or service name');
+ return s;
+ }
+ function validateSourceRef(ref) {
+ if (ref == null) return null;
+ if (typeof ref !== 'object' || Array.isArray(ref)) throw new AiError(422, 'input.invalid', 'source_ref must be { path, heading }');
+ const out = {};
+ if (ref.path != null) out.path = String(ref.path).slice(0, 500);
+ if (ref.heading != null) out.heading = String(ref.heading).slice(0, 300);
+ if (!out.path && !out.heading) throw new AiError(422, 'input.invalid', 'source_ref must name a path or a heading');
+ return out;
+ }
+ function validateTime(value, fallback) {
+ if (value == null) return fallback;
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) throw new AiError(422, 'input.invalid', 'a time is not an ISO date-time');
+ return d.toISOString();
+ }
+
+ function itemView(row) {
+ const text = open(row.text_enc, aadFor(row.owner, row.id, row.scope));
+ let sourceRef = null;
+ if (row.source_ref_enc) {
+ try { sourceRef = JSON.parse(open(row.source_ref_enc, aadFor(row.owner, row.id, row.scope))); } catch (err) { throw err instanceof AiError ? err : new AiError(500, 'memory.unreadable', 'a memory row does not open'); }
+ }
+ return {
+ id: row.id, owner: row.owner, scope: row.scope, kind: row.kind, text,
+ source: row.source, source_ref: sourceRef, status: row.status, confidence: Number(row.confidence),
+ seen_at: row.seen_at, created_at: row.created_at, updated_at: row.updated_at,
+ machine: row.machine || null,
+ ...(AI_SOURCES.has(String(row.source).split(',')[0]) ? { no_looks: true } : {}),
+ };
+ }
+
+ function requireScope(req, scope) {
+ if (req.principal.kind === 'user') return;
+ if (!grantedScopes(req.principal.claims).has(scope)) throw new AiError(403, 'memory.scope_denied', `the token's grant does not name ${scope}`);
+ }
+
+ /** Import up to 200 items. Dedupe is by text_mac within owner and scope; a repeat updates seen_at and the source. */
+ async function batch(req, owner) {
+ assertConfigured();
+ const items = req.body && req.body.items;
+ if (!Array.isArray(items) || items.length < 1 || items.length > MAX_BATCH) throw new AiError(422, 'input.invalid', `items must be an array of 1 to ${MAX_BATCH} items`);
+ const serviceCaller = req.principal.kind !== 'user';
+ const at = iso(clock.now());
+ // Redact and refuse before writing anything: one secret-only item refuses the batch.
+ const prepared = items.map((raw, index) => {
+ if (!raw || typeof raw !== 'object') throw new AiError(422, 'input.invalid', `items[${index}] is not an object`);
+ const clean = redact(String(raw.text == null ? '' : raw.text)).text;
+ if (!clean.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty`);
+ if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);
+ const scope = validateScope(raw.scope, 'user');
+ requireScope(req, scope);
+ // A service writes under its own name, never a body source; the person's own token keeps the body source.
+ const serviceSource = req.principal.subject && req.principal.subject.id
+ ? req.principal.subject.id
+ : String(req.principal.sub || '').replace(/^svc:/, '');
+ const source = validateSource(serviceCaller ? serviceSource : (raw.source || 'person'));
+ // A service is an agent: what it writes is a suggestion, and it passes noLooks. A person may mark their own
+ // item as AI-produced too (the Node imports what a coding tool wrote).
+ const aiMade = serviceCaller || raw.no_looks === true || AI_SOURCES.has(source);
+ let text = aiMade ? noLooks(clean) : clean;
+ text = String(text == null ? '' : text);
+ if (!text.trim()) throw new AiError(422, 'input.invalid', `items[${index}].text is empty after no-looks`);
+ if (onlySecret(text)) throw new AiError(422, 'memory.secret_only', `items[${index}] is only a secret and is refused`);
+ const askedConfidence = raw.confidence == null ? null : Number(raw.confidence);
+ const confidence = askedConfidence == null || !Number.isFinite(askedConfidence)
+ ? (serviceCaller ? 0.5 : 1)
+ : Math.max(0, Math.min(serviceCaller ? 0.5 : 1, askedConfidence));
+ return {
+ id: `mem_${ids.ulid()}`,
+ scope,
+ kind: validateKind(raw.kind),
+ text,
+ mac: macOf(text),
+ source,
+ sourceRef: validateSourceRef(raw.source_ref),
+ // A service or an AI may only suggest until the person accepts it; the body cannot raise that.
+ status: serviceCaller || aiMade ? 'suggested' : validateStatus(raw.status, 'active'),
+ confidence,
+ seenAt: validateTime(raw.seen_at, at),
+ machine: raw.machine == null ? null : String(raw.machine).slice(0, 100),
+ };
+ });
+ const out = [];
+ let created = 0;
+ let deduped = 0;
+ await db.tx(async () => {
+ for (const it of prepared) {
+ const prev = await q('SELECT * FROM memory_items WHERE owner = $1 AND scope = $2 AND text_mac = $3 LIMIT 1').get(owner, it.scope, it.mac);
+ if (prev) {
+ const sources = String(prev.source || '').split(',').filter(Boolean);
+ if (!sources.includes(it.source)) sources.push(it.source);
+ await q('UPDATE memory_items SET seen_at = $1, updated_at = $2, source = $3 WHERE owner = $4 AND id = $5')
+ .run(it.seenAt > prev.seen_at ? it.seenAt : prev.seen_at, at, sources.join(',').slice(0, 500), owner, prev.id);
+ deduped++;
+ out.push(itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, prev.id)));
+ continue;
+ }
+ await q(`INSERT INTO memory_items (id, owner, scope, kind, text_enc, text_mac, source, source_ref_enc, status, confidence, seen_at, created_at, updated_at, machine)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`)
+ .run(it.id, owner, it.scope, it.kind, seal(it.text, aadFor(owner, it.id, it.scope)), it.mac, it.source,
+ it.sourceRef ? seal(JSON.stringify(it.sourceRef), aadFor(owner, it.id, it.scope)) : null,
+ it.status, it.confidence, it.seenAt, at, at, it.machine);
+ created++;
+ out.push(itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, it.id)));
+ }
+ });
+ return { items: out, count: created + deduped, created, deduped };
+ }
+
+ async function list(req, owner) {
+ assertConfigured();
+ const scope = req.query.scope ? validateScope(req.query.scope) : null;
+ if (scope) requireScope(req, scope);
+ const status = req.query.status ? validateStatus(req.query.status) : null;
+ const allowed = [...grantedScopes(req.principal.claims)];
+ let sql = 'SELECT * FROM memory_items WHERE owner = $1';
+ const args = [owner];
+ if (scope) { args.push(scope); sql += ` AND scope = $${args.length}`; }
+ else if (req.principal.kind !== 'user') { args.push(allowed); sql += ` AND scope = ANY($${args.length}::text[])`; }
+ if (status) { args.push(status); sql += ` AND status = $${args.length}`; }
+ sql += ' ORDER BY updated_at DESC LIMIT 2000';
+ const rows = await q(sql).all(...args);
+ const searchText = req.query.q == null ? '' : String(req.query.q).slice(0, 200);
+ const items = [];
+ for (const row of rows.slice(0, MAX_LIST)) {
+ const view = itemView(row);
+ if (!searchText || view.text.toLowerCase().includes(searchText.toLowerCase())) items.push(view);
+ if (items.length >= 200) break;
+ }
+ return { items, count: items.length };
+ }
+
+ async function get(req, owner, id) {
+ assertConfigured();
+ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);
+ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');
+ requireScope(req, row.scope);
+ return { item: itemView(row) };
+ }
+
+ async function patch(req, owner, id) {
+ assertConfigured();
+ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);
+ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');
+ requireScope(req, row.scope);
+ const body = req.body || {};
+ const sets = [];
+ const args = [];
+ const add = (column, value) => { args.push(value); sets.push(`${column} = $${args.length}`); };
+ let nextScope = row.scope;
+ let nextText = null;
+ if (body.scope !== undefined) {
+ nextScope = validateScope(body.scope);
+ requireScope(req, nextScope);
+ add('scope', nextScope);
+ }
+ if (body.kind !== undefined) add('kind', validateKind(body.kind));
+ if (body.status !== undefined) add('status', validateStatus(body.status));
+ if (body.text !== undefined) {
+ const clean = redact(String(body.text)).text;
+ if (!clean.trim()) throw new AiError(422, 'input.invalid', 'text is empty');
+ if (onlySecret(clean)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
+ nextText = body.no_looks === true ? String(noLooks(clean)) : clean;
+ if (onlySecret(nextText)) throw new AiError(422, 'memory.secret_only', 'an item that is only a secret is refused');
+ add('text_enc', seal(nextText, aadFor(owner, row.id, nextScope)));
+ add('text_mac', macOf(nextText));
+ }
+ if (body.source !== undefined) add('source', validateSource(body.source));
+ if (body.source_ref !== undefined) {
+ const ref = validateSourceRef(body.source_ref);
+ add('source_ref_enc', ref ? seal(JSON.stringify(ref), aadFor(owner, row.id, nextScope)) : null);
+ }
+ if (!sets.length) throw new AiError(422, 'input.invalid', 'nothing to change: send text, kind, scope, status, source or source_ref');
+ args.push(iso(clock.now()));
+ const updatedAt = `$${args.length}`;
+ // Re-seal when the scope changed even if only the scope was sent: the AAD names it.
+ if (nextScope !== row.scope && nextText === null && !sets.some((s) => s.startsWith('text_enc'))) {
+ const text = open(row.text_enc, aadFor(owner, row.id, row.scope));
+ add('text_enc', seal(text, aadFor(owner, row.id, nextScope)));
+ if (row.source_ref_enc) {
+ const ref = open(row.source_ref_enc, aadFor(owner, row.id, row.scope));
+ add('source_ref_enc', seal(ref, aadFor(owner, row.id, nextScope)));
+ }
+ }
+ args.push(owner, row.id);
+ await q(`UPDATE memory_items SET ${sets.join(', ')}, updated_at = ${updatedAt} WHERE owner = $${args.length - 1} AND id = $${args.length}`).run(...args);
+ // A profile built from the old text, kind or scope will not match its items_hash again: drop it now.
+ const scopes = [...new Set([row.scope, nextScope])];
+ await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = ANY($2::text[])').run(owner, scopes);
+ return { item: itemView(await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, row.id)) };
+ }
+
+ async function remove(req, owner, id) {
+ assertConfigured();
+ const row = await q('SELECT * FROM memory_items WHERE owner = $1 AND id = $2').get(owner, id);
+ if (!row) throw new AiError(404, 'memory.not_found', 'no such memory item');
+ requireScope(req, row.scope);
+ await db.tx(async () => {
+ await q('DELETE FROM memory_items WHERE owner = $1 AND id = $2').run(owner, row.id);
+ await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = $2').run(owner, row.scope);
+ });
+ return { deleted: 1 };
+ }
+
+ async function removeBySource(req, owner) {
+ assertConfigured();
+ const source = validateSource(req.query.source);
+ const rows = await q("SELECT DISTINCT scope FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").all(owner, source);
+ const scopes = rows.map((r) => r.scope);
+ let deleted = 0;
+ await db.tx(async () => {
+ deleted = Number((await q("SELECT COUNT(*)::int AS n FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").get(owner, source)).n) || 0;
+ await q("DELETE FROM memory_items WHERE owner = $1 AND string_to_array(source, ',') @> ARRAY[$2]::text[]").run(owner, source);
+ if (scopes.length) await q('DELETE FROM memory_profiles WHERE owner = $1 AND scope = ANY($2::text[])').run(owner, scopes);
+ });
+ return { deleted };
+ }
+
+ /** Items from one scope, active only: what a profile is built from and what an agent may see. */
+ async function activeRows(owner, scope) {
+ return await q("SELECT * FROM memory_items WHERE owner = $1 AND scope = $2 AND status = 'active' ORDER BY updated_at DESC LIMIT 2000").all(owner, scope);
+ }
+
+ function itemsHash(macs) {
+ return sha256([...macs].sort().join('\n'));
+ }
+
+ async function summarize({ owner, scope, items }) {
+ if (buildProfile) return await buildProfile({ owner, scope, items });
+ const notes = items.map((it) => `- (${it.kind}) ${it.text}`).join('\n').slice(0, 40000);
+ const prompt = [
+ `Write a short profile of the person from their own memory notes for ${scope}.`,
+ 'Rules: summarize only what the notes say. Never guess or infer facts about the person.',
+ 'Never describe looks, body, clothing, hair, face, pose, age or a guessed gender: no looks, at any time.',
+ 'Write at most 400 words and do not add a title.',
+ '',
+ notes,
+ ].join('\n');
+ if (!registry || !pool) return notes.split('\n').slice(0, 40).join('\n').slice(0, 4000);
+ // The platform's own cheap route, called in-process: no run row, no on_behalf_of and no person attribution,
+ // so the notes are not stored again (a run's input would keep their plaintext) and the person's allowance is
+ // not spent. A route of its own would be the cheap tier; default.chat is the service's smallest chat pool.
+ const route = await registry.resolveRoute('default.chat');
+ if (!route || route.disabled) throw new AiError(503, 'memory.profile_failed', 'no route is available to build a profile');
+ const exec = await pool.execute(route, 'summarize', {
+ system: [{ text: 'You write short, faithful profiles of a person from their own notes. Never guess, never describe looks or a body, and never add a fact the notes do not state.', cache: true }],
+ messages: [{ role: 'user', content: prompt }],
+ maxTokens: 400,
+ temperature: 0.2,
+ timeoutMs: 30000,
+ }, { routeKey: route.key, routeVersion: route.version });
+ return String((exec.result && exec.result.text) || '');
+ }
+
+ async function profile(req, owner) {
+ assertConfigured();
+ const scope = validateScope(req.query.scope, 'user');
+ requireScope(req, scope);
+ const rows = await activeRows(owner, scope);
+ const hash = itemsHash(rows.map((r) => r.text_mac));
+ const now = clock.now();
+ const cached = await q('SELECT * FROM memory_profiles WHERE owner = $1 AND scope = $2').get(owner, scope);
+ if (cached) {
+ const age = now - Date.parse(cached.built_at);
+ const fresh = Number.isFinite(age) && age < PROFILE_INTERVAL_MS;
+ if (fresh || cached.items_hash === hash) {
+ const text = open(cached.text_enc, aadFor(owner, PROFILE_ID, scope));
+ return { profile: { scope, text, items_hash: cached.items_hash, built_at: cached.built_at } };
+ }
+ }
+ if (!rows.length) {
+ const builtAt = iso(now);
+ const empty = seal('', aadFor(owner, PROFILE_ID, scope));
+ await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)
+ .run(owner, scope, empty, hash, builtAt);
+ return { profile: { scope, text: '', items_hash: hash, built_at: builtAt } };
+ }
+ const items = rows.map((r) => ({ kind: r.kind, text: open(r.text_enc, aadFor(owner, r.id, r.scope)) }));
+ const text = String(noLooks(await summarize({ owner, scope, items })) || '').slice(0, 4000);
+ const builtAt = iso(now);
+ await q(`INSERT INTO memory_profiles (owner, scope, text_enc, items_hash, built_at) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (owner, scope) DO UPDATE SET text_enc = excluded.text_enc, items_hash = excluded.items_hash, built_at = excluded.built_at`)
+ .run(owner, scope, seal(text, aadFor(owner, PROFILE_ID, scope)), hash, builtAt);
+ return { profile: { scope, text, items_hash: hash, built_at: builtAt } };
+ }
+
+ /** An agent's search: active items only, the granted scopes, at most 8, and one memory_reads row with ids only. */
+ async function search(req, owner) {
+ assertConfigured();
+ const raw = req.query.scopes == null || req.query.scopes === '' ? 'user' : String(req.query.scopes);
+ const scopes = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))];
+ if (!scopes.length) throw new AiError(422, 'input.invalid', 'scopes must name at least one scope');
+ for (const s of scopes) {
+ validateScope(s);
+ requireScope(req, s);
+ }
+ const searchText = req.query.q == null ? '' : String(req.query.q).slice(0, 500).toLowerCase();
+ const rows = await q("SELECT * FROM memory_items WHERE owner = $1 AND status = 'active' AND scope = ANY($2::text[]) ORDER BY updated_at DESC LIMIT 200").all(owner, scopes);
+ const items = [];
+ for (const row of rows) {
+ const view = itemView(row);
+ if (!searchText || view.text.toLowerCase().includes(searchText)) items.push(view);
+ if (items.length >= MAX_SEARCH) break;
+ }
+ if (req.principal.kind !== 'user') {
+ const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);
+ await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')
+ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, purpose ? purpose.slice(0, 200) : null, JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));
+ }
+ return { items, count: items.length };
+ }
+
+ /** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */
+ async function exportFor(subject) {
+ assertConfigured();
+ const owner = `user:${subject}`;
+ const items = (await q('SELECT * FROM memory_items WHERE owner = $1 ORDER BY created_at DESC LIMIT 5000').all(owner)).map(itemView);
+ const profiles = (await q('SELECT * FROM memory_profiles WHERE owner = $1 ORDER BY built_at DESC').all(owner))
+ .map((r) => ({ scope: r.scope, text: open(r.text_enc, aadFor(owner, PROFILE_ID, r.scope)), items_hash: r.items_hash, built_at: r.built_at }));
+ const reads = await q('SELECT id, actor, purpose, scopes, item_ids, created_at FROM memory_reads WHERE owner = $1 ORDER BY created_at DESC LIMIT 5000').all(owner);
+ return [
+ { name: 'memory-items.json', content: items },
+ { name: 'memory-profiles.json', content: profiles },
+ { name: 'memory-reads.json', content: reads.map((r) => ({ ...r, scopes: JSON.parse(r.scopes || '[]'), item_ids: JSON.parse(r.item_ids || '[]') })) },
+ ];
+ }
+
+ return { assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, aadFor, macOf, seal, open };
+}
+
+/**
+ * The memory routes. A person's own Network token acts for themselves; a service token needs the capability and
+ * names the person with `on_behalf_of`, but it may only search (audited) and import suggestions. Every other route is
+ * person-only: a service gets 403 `capability.denied`.
+ */
+function memoryRouter({ memory, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {
+ const r = express.Router();
+ function guard(check) {
+ return async function memoryGuard(req, res, next) {
+ const ctx = req.ov;
+ const token = bearer(req);
+ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a person token or a service token (audience openvibe.ai) is required', ctx });
+ let service = { ok: false };
+ try { service = await auth.verify(token); } catch (err) { service = { ok: false, code: 'token.invalid', reason: 'invalid token' }; }
+ if (service.ok && !isAppClaims(service.claims)) {
+ const decision = check(service.claims);
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || 'the capability is not granted', ctx });
+ req.principal = { kind: 'service', sub: service.claims.sub, subject: principalSubject(service.claims.sub), cap: service.claims.cap, ns: service.claims.ns || [], jti: service.claims.jti, claims: service.claims };
+ return next();
+ }
+ if (service.ok) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });
+ // A person's Network token: the same key and issuer, no principal type. The subject is subject_id (usr_).
+ try {
+ const claims = await verifyUserToken(token, {
+ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
+ issuer: config.issuer,
+ audience: ['openvibe.network', 'openvibe.ai'],
+ now: clock.now(),
+ log,
+ });
+ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
+ if (!subject) throw new AiError(401, 'token.invalid', 'the person token has no usr_ subject');
+ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };
+ return next();
+ } catch (err) {
+ return http.sendProblem(res, err.status || 401, err.code || 'token.invalid', { detail: err.detail || 'invalid token', ctx });
+ }
+ };
+ }
+ const run = (fn) => async (req, res) => {
+ try { res.set('Cache-Control', 'no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }
+ };
+ /**
+ * The person's own routes. A service with the read or write capability may only search (the audited agent read)
+ * and import suggestions; it may not browse, profile, patch or delete the person's memory. The guard runs after
+ * `guard`, which has set req.principal.
+ */
+ function personOnly(req, res, next) {
+ if (req.principal.kind === 'service') {
+ return http.sendProblem(res, 403, 'capability.denied', { detail: "this route is for the person's own token", ctx: req.ov });
+ }
+ return next();
+ }
+ r.post('/api/v1/memory/items\\:batch', guard(WRITE_CAP), run(async (req) => await memory.batch(req, ownerOf(req))));
+ r.get('/api/v1/memory/items', guard(READ_CAP), personOnly, run(async (req) => await memory.list(req, ownerOf(req))));
+ r.get('/api/v1/memory/items/:id', guard(READ_CAP), personOnly, run(async (req) => await memory.get(req, ownerOf(req), req.params.id)));
+ r.patch('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.patch(req, ownerOf(req), req.params.id)));
+ r.delete('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.remove(req, ownerOf(req), req.params.id)));
+ r.delete('/api/v1/memory/items', guard(WRITE_CAP), personOnly, run(async (req) => await memory.removeBySource(req, ownerOf(req))));
+ r.get('/api/v1/memory/profile', guard(READ_CAP), personOnly, run(async (req) => await memory.profile(req, ownerOf(req))));
+ r.get('/api/v1/memory/search', guard(READ_CAP), run(async (req) => await memory.search(req, ownerOf(req))));
+ // The owner is resolved after the guard has set the principal.
+ function ownerOf(req) {
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const supplied = body.on_behalf_of != null ? body.on_behalf_of : req.query && req.query.on_behalf_of;
+ if (req.principal.kind === 'user') {
+ if (supplied != null && supplied !== req.principal.subject.id) throw new AiError(403, 'capability.denied', 'a person token acts only for its own owner');
+ return `user:${req.principal.subject.id}`;
+ }
+ const claimed = req.principal.claims.on_behalf_of;
+ if (supplied != null && claimed != null && String(supplied) !== String(claimed)) throw new AiError(403, 'capability.denied', 'on_behalf_of does not match the token');
+ const person = claimed != null ? claimed : supplied;
+ if (person == null || !SUBJECT_RE.test(String(person))) throw new AiError(422, 'input.invalid', 'on_behalf_of must name a usr_ subject');
+ return `user:${person}`;
+ }
+ return r;
+}
+
+module.exports = { createMemory, memoryRouter, PROFILE_INTERVAL_MS };
@@ -77,6 +77,58 @@ function route(method, p, operation) {
@@ -77,6 +77,58 @@ function route(method, p, operation) {
(PATHS[p] = PATHS[p] || {})[method] = operation;
(PATHS[p] = PATHS[p] || {})[method] = operation;
}
}
+// ── Memory (Memory M2a) ──────────────────────────────────────────────────────────────────────
+const memoryItem = ref('MemoryItem');
+const memoryBody = ref('MemoryWriteRequest');
+const memoryResult = ref('MemoryResult');
+const memorySecurity = SERVICE; // the person's own token, or a service token plus on_behalf_of
+route('post', '/memory/items:batch', {
+ operationId: 'importMemoryItems', summary: 'Import up to 200 memory items', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. Redaction runs again on the server; an item that is only a secret is refused. Dedupe is by text_mac within owner and scope: the same text updates seen_at and adds the source. AI-produced items are suggestions until the person accepts them.',
+ requestBody: body(memoryBody),
+ responses: { 200: json('The stored (or deduped) items', memoryResult), 422: problem, 503: problem, ...errors([400, 401, 403]) },
+});
+route('get', '/memory/items', {
+ operationId: 'listMemoryItems', summary: 'List the person’s own memory items', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. q searches decrypted text in-process over that owner’s rows only, capped at 2,000.',
+ parameters: [query('scope', str()), query('status', str({ enum: ['active', 'suggested', 'archived', 'conflict'] })), query('q', str()), query('on_behalf_of', str())],
+ responses: { 200: json('The person’s items', memoryResult), 403: problem, 503: problem, ...errors([401]) },
+});
+route('delete', '/memory/items', {
+ operationId: 'deleteMemorySource', summary: 'Delete everything from one source', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. Delete means gone: the rows are removed and the profiles that used them are rebuilt.',
+ parameters: [{ ...query('source', str(), 'The adapter id or service to delete'), required: true }, query('on_behalf_of', str())],
+ responses: { 200: json('How many rows were removed', memoryResult), 422: problem, 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/items/{id}', {
+ operationId: 'getMemoryItem', summary: 'Read one memory item', tags: ['memory'], security: memorySecurity,
+ parameters: [path('id', str()), query('on_behalf_of', str())],
+ responses: { 200: json('The item', memoryResult), 404: problem, 503: problem, ...errors([401, 403]) },
+});
+route('patch', '/memory/items/{id}', {
+ operationId: 'updateMemoryItem', summary: 'Change text, kind, scope or status', tags: ['memory'], security: memorySecurity,
+ parameters: [path('id', str())],
+ requestBody: body(memoryBody),
+ responses: { 200: json('The changed item', memoryResult), 404: problem, 422: problem, 503: problem, ...errors([401, 403]) },
+});
+route('delete', '/memory/items/{id}', {
+ operationId: 'deleteMemoryItem', summary: 'Delete one memory item', tags: ['memory'], security: memorySecurity,
+ parameters: [path('id', str()), query('on_behalf_of', str())],
+ responses: { 200: json('How many rows were removed', memoryResult), 404: problem, 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/profile', {
+ operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result passes noLooks.',
+ parameters: [query('scope', str()), query('on_behalf_of', str())],
+ responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/search', {
+ operationId: 'searchMemoryForAgent', summary: 'An agent reads active memory in its granted scopes', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read plus on_behalf_of. Active items only, at most 8, in the scopes the token’s grant names (user always; project:* only when granted). The read is audited in memory_reads with item ids, never text.',
+ parameters: [query('q', str()), query('scopes', str(), 'Comma-separated scopes; default user'), query('purpose', str(), 'The task or run the person sees'), query('on_behalf_of', str())],
+ responses: { 200: json('At most 8 active items', memoryResult), 403: problem, 503: problem, ...errors([401]) },
+});
+
// ── Runs ──────────────────────────────────────────────────────────────────────────────────────
// ── Runs ──────────────────────────────────────────────────────────────────────────────────────
route('post', '/runs', {
route('post', '/runs', {
operationId: 'createRun', summary: 'Start a workflow run (201 finished, 202 queued or running, 200 replay)', tags: ['runs'], security: SERVICE,
operationId: 'createRun', summary: 'Start a workflow run (201 finished, 202 queued or running, 200 replay)', tags: ['runs'], security: SERVICE,
@@ -234,6 +286,29 @@ function openapi() {
@@ -234,6 +286,29 @@ function openapi() {
snippet: nullable('string'), content_hash: nullable('string'), trust: { type: 'object' }, provenance: { type: 'object' },
snippet: nullable('string'), content_hash: nullable('string'), trust: { type: 'object' }, provenance: { type: 'object' },
attached_by: str(), created_at: str({ format: 'date-time' }),
attached_by: str(), created_at: str({ format: 'date-time' }),
} },
} },
+ MemoryItem: { type: 'object', required: ['id', 'owner', 'scope', 'kind', 'text', 'source', 'status', 'confidence', 'created_at', 'updated_at'], properties: {
+ id: str({ pattern: '^mem_[0-9A-HJKMNP-TV-Z]{26}$' }), owner: str(), scope: str(),
+ kind: str({ enum: ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'] }),
+ text: str(), source: str(), source_ref: nullable('object'),
+ status: str({ enum: ['active', 'suggested', 'archived', 'conflict'] }), confidence: { type: 'number' },
+ seen_at: str({ format: 'date-time' }), created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),
+ machine: nullable('string'), no_looks: { type: 'boolean' },
+ } },
+ MemoryProfile: { type: 'object', properties: {
+ scope: str(), text: str(), items_hash: str({ pattern: '^[0-9a-f]{64}$' }), built_at: str({ format: 'date-time' }),
+ } },
+ MemoryResult: { type: 'object', properties: {
+ items: arrayOf(ref('MemoryItem')), item: ref('MemoryItem'), profile: ref('MemoryProfile'),
+ deleted: integer({ minimum: 0 }), count: integer({ minimum: 0 }),
+ } },
+ MemoryWriteRequest: { type: 'object', properties: {
+ items: arrayOf(ref('MemoryItemWrite')), on_behalf_of: str(), text: str(), kind: str(), scope: str(), status: str(), source: str(),
+ } },
+ MemoryItemWrite: { type: 'object', required: ['text'], properties: {
+ text: str(), kind: str(), scope: str(), source: str(), source_ref: nullable('object'), on_behalf_of: str(),
+ status: str({ enum: ['active', 'suggested', 'archived', 'conflict'] }), confidence: { type: 'number' },
+ machine: nullable('string'), no_looks: { type: 'boolean' }, seen_at: str({ format: 'date-time' }),
+ } },
ActionItem: { type: 'object', required: ['service', 'id', 'kind', 'title', 'bucket', 'state'], properties: {
ActionItem: { type: 'object', required: ['service', 'id', 'kind', 'title', 'bucket', 'state'], properties: {
service: { const: 'ai' }, id: str(), kind: { const: 'ai_run' }, title: str(), summary: nullable('string'),
service: { const: 'ai' }, id: str(), kind: { const: 'ai_run' }, title: str(), summary: nullable('string'),
bucket: { type: 'string', enum: ['active', 'done', 'failed'] }, state: str(), agent: nullable('string'),
bucket: { type: 'string', enum: ['active', 'done', 'failed'] }, state: str(), agent: nullable('string'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,357 @@
@@ -0,0 +1,357 @@
+'use strict';
+/**
+ * Memory M2a: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner scope,
+ * the agent search (active only, at most 8, audited), the profile cache, delete, the 503 without a key, account data
+ * and the no-plaintext-in-logs rule.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { uncovered } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, request, suite, token, privateKey, ISSUER } = require('./helpers');
+const { TABLES } = require('../server/account-data');
+const { createMemory } = require('../server/memory');
+
+const t = suite('memory');
+const KEY = 'a'.repeat(64);
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+const SECRET = `whsec_${'memory'.repeat(5)}`;
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+
+/** A person's Network access token: no actor_type, the subject in subject_id. */
+function userToken(subject, { aud = ['openvibe.network'] } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud, iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+const serviceWrite = (extra = {}) => token('codes', ['ai.memory.write'], { extra });
+const serviceRead = (extra = {}) => token('codes', ['ai.memory.read'], { extra });
+const item = (text, extra = {}) => ({ text, ...extra });
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_ai', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+t.test('without AI_MEMORY_KEY every route answers 503 memory.unavailable', async () => {
+ const h = await boot();
+ try {
+ const r = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite(), body: { items: [item('a note')], on_behalf_of: A } });
+ assert.deepStrictEqual([r.status, r.body.code], [503, 'memory.unavailable']);
+ const g = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(A) });
+ assert.deepStrictEqual([g.status, g.body.code], [503, 'memory.unavailable']);
+ } finally { await h.stop(); }
+});
+
+t.test('sealing is a round trip and the AAD binds owner, id and scope', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const aad = h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M01', 'project:demo');
+ const sealed = h.memory.seal('the note', aad);
+ assert.match(sealed, /^m1:/);
+ assert.equal(h.memory.open(sealed, aad), 'the note');
+ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${B}`, 'mem_01JZ0000000000000000000M01', 'project:demo')), /does not open/);
+ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M01', 'user')), /does not open/);
+ assert.throws(() => h.memory.open(sealed, h.memory.aadFor(`user:${A}`, 'mem_01JZ0000000000000000000M02', 'project:demo')), /does not open/);
+ } finally { await h.stop(); }
+});
+
+t.test('a batch stores sealed text, dedupes by MAC and redacts a secret again on the server', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const first = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [
+ item('Prefers short answers.', { source: 'cursor', scope: 'user' }),
+ item(`the deploy token is ${'ghp_' + 'A'.repeat(36)}`, { source: 'claude-code' }),
+ ] } });
+ assert.deepStrictEqual([first.status, first.body.count, first.body.created, first.body.deduped], [200, 2, 2, 0]);
+ assert.equal(first.body.items[1].text.includes('ghp_'), false);
+ assert.ok(first.body.items[1].text.includes('[redacted]'));
+ assert.ok(!('text_enc' in first.body.items[0]) && !('text_mac' in first.body.items[0]), 'sealed fields never leave the module');
+
+ const row = await h.db.prepare('SELECT * FROM memory_items WHERE owner = ? ORDER BY created_at LIMIT 1').get(`user:${A}`);
+ assert.match(row.text_enc, /^m1:/);
+ assert.match(row.text_mac, /^[0-9a-f]{64}$/);
+ assert.ok(!('text' in row), 'there is no plaintext column');
+
+ const again = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('Prefers short answers.', { source: 'claude-code' })] } });
+ assert.deepStrictEqual([again.status, again.body.created, again.body.deduped], [200, 0, 1]);
+ const rows = await h.db.prepare('SELECT source FROM memory_items WHERE owner = ? AND scope = ?').all(`user:${A}`, 'user');
+ assert.equal(rows.length, 2);
+ assert.match(rows.find((r) => r.source.includes('cursor')).source, /cursor,claude-code|claude-code,cursor/);
+
+ const refused = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('sk-' + 'proj-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8')] } });
+ assert.deepStrictEqual([refused.status, refused.body.code], [422, 'memory.secret_only']);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${A}`])), 2);
+ } finally { await h.stop(); }
+});
+
+t.test('one person cannot read, change or delete another person\'s item, and every query is owner-scoped', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('A private note')] } });
+ const id = made.body.items[0].id;
+ const asB = userToken(B);
+ assert.equal((await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: asB })).status, 404);
+ assert.equal((await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: asB, body: { text: 'hijacked' } })).status, 404);
+ assert.equal((await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: asB })).status, 404);
+ const list = await request(h.base, 'GET', '/api/v1/memory/items', { tok: asB });
+ assert.deepStrictEqual([list.status, list.body.items.length], [200, 0]);
+
+ const dir = path.join(__dirname, '..', 'server', 'memory');
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
+ const problems = [];
+ for (const f of files) {
+ const src = fs.readFileSync(path.join(dir, f), 'utf8');
+ for (const m of src.matchAll(/(['"`])((?:(?!\1)[\s\S])*?(?:memory_items|memory_profiles|memory_reads)[\s\S]*?)\1/g)) {
+ const sql = m[2].trim();
+ if (!/^(SELECT|UPDATE|DELETE)\b/i.test(sql)) continue;
+ if (!/owner = \$/.test(sql)) problems.push(`${f}: ${sql.split('\n')[0]}`);
+ }
+ }
+ assert.deepStrictEqual(problems, [], `a memory query is not owner-scoped: ${problems.join(' | ')}`);
+ } finally { await h.stop(); }
+});
+
+t.test('an agent sees active items only, at most 8, in its granted scopes, and the read is audited with ids only', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const items = [];
+ for (let i = 0; i < 10; i++) items.push(item(`active note ${i}`, { scope: 'user' }));
+ items.push(item('suggested note', { scope: 'user', status: 'suggested' }));
+ items.push(item('archived note', { scope: 'user', status: 'archived' }));
+ items.push(item('project note', { scope: 'project:demo' }));
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items } });
+ assert.equal(made.status, 200);
+
+ const grant = { memory_scopes: ['user', 'project:demo'] };
+ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=note&scopes=user&purpose=task-9&on_behalf_of=${A}`, { tok: serviceRead(grant) });
+ assert.equal(search.status, 200);
+ assert.equal(search.body.items.length, 8);
+ assert.ok(search.body.items.every((i) => i.status === 'active' && i.scope === 'user'));
+ assert.ok(!search.text.includes('suggested note') && !search.text.includes('archived note'));
+
+ const audit = await h.db.prepare('SELECT * FROM memory_reads WHERE owner = ?').get(`user:${A}`);
+ assert.equal(audit.actor, 'svc:codes');
+ assert.equal(audit.purpose, 'task-9');
+ const ids = JSON.parse(audit.item_ids);
+ assert.equal(ids.length, 8);
+ assert.ok(ids.every((id) => id.startsWith('mem_')));
+ assert.ok(!audit.item_ids.includes('active note'), 'the audit holds ids, never text');
+
+ const project = await request(h.base, 'GET', `/api/v1/memory/search?q=project&scopes=project:demo&on_behalf_of=${A}`, { tok: serviceRead(grant) });
+ assert.deepStrictEqual([project.status, project.body.items.length], [200, 1]);
+ const denied = await request(h.base, 'GET', `/api/v1/memory/search?q=project&scopes=project:demo&on_behalf_of=${A}`, { tok: serviceRead() });
+ assert.deepStrictEqual([denied.status, denied.body.code], [403, 'memory.scope_denied']);
+
+ const mismatch = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite({ on_behalf_of: A }), body: { items: [item('x')], on_behalf_of: B } });
+ assert.deepStrictEqual([mismatch.status, mismatch.body.code], [403, 'capability.denied']);
+ } finally { await h.stop(); }
+});
+
+t.test('a service may only search and suggest: every other memory route is person-only', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item("the person's active note")] } });
+ const id = made.body.items[0].id;
+ const read = serviceRead({ memory_scopes: ['user'] });
+ const denied = (r) => {
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied']);
+ assert.match(r.body.detail, /person's own token/);
+ };
+ denied(await request(h.base, 'GET', `/api/v1/memory/items?on_behalf_of=${A}`, { tok: read }));
+ denied(await request(h.base, 'GET', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: read }));
+ denied(await request(h.base, 'GET', `/api/v1/memory/profile?scope=user&on_behalf_of=${A}`, { tok: read }));
+ denied(await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: serviceWrite(), body: { text: 'hijacked', on_behalf_of: A } }));
+ denied(await request(h.base, 'DELETE', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: serviceWrite() }));
+ denied(await request(h.base, 'DELETE', `/api/v1/memory/items?source=person&on_behalf_of=${A}`, { tok: serviceWrite() }));
+ // None of the refusals touched the person's row.
+ const still = await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });
+ assert.deepStrictEqual([still.status, still.body.item.text, still.body.item.status], [200, "the person's active note", 'active']);
+
+ // A service's write is a suggestion under its own name, whatever the body says.
+ const suggested = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: serviceWrite(), body: { items: [
+ item('a service suggestion', { status: 'active', source: 'person', confidence: 0.9 }),
+ ], on_behalf_of: A } });
+ assert.deepStrictEqual([suggested.status, suggested.body.items[0].status, suggested.body.items[0].source], [200, 'suggested', 'codes']);
+ assert.ok(suggested.body.items[0].confidence <= 0.5, `confidence ${suggested.body.items[0].confidence} > 0.5`);
+ const row = await h.db.prepare('SELECT status, source, confidence FROM memory_items WHERE owner = ? AND id = ?').get(`user:${A}`, suggested.body.items[0].id);
+ assert.deepStrictEqual([row.status, row.source, Number(row.confidence)], ['suggested', 'codes', 0.5]);
+
+ // Search still works for a service, and it is audited.
+ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&on_behalf_of=${A}`, { tok: read });
+ assert.deepStrictEqual([search.status, search.body.items.length], [200, 1]);
+ const audit = await h.db.prepare('SELECT actor, item_ids FROM memory_reads WHERE owner = ?').get(`user:${A}`);
+ assert.equal(audit.actor, 'svc:codes');
+ assert.ok(!audit.item_ids.includes('active note'));
+ } finally { await h.stop(); }
+});
+
+t.test('a person token still writes active items, and still reads, changes and deletes them', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [
+ item('a person item', { status: 'active', source: 'claude-code', confidence: 1 }),
+ ] } });
+ assert.deepStrictEqual([made.status, made.body.items[0].status, made.body.items[0].source, made.body.items[0].confidence], [200, 'active', 'claude-code', 1]);
+ const id = made.body.items[0].id;
+ const list = await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });
+ assert.deepStrictEqual([list.body.items.length, (await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who })).status], [1, 200]);
+ const patched = await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { text: 'a person edit' } });
+ assert.deepStrictEqual([patched.status, patched.body.item.text], [200, 'a person edit']);
+ assert.equal((await request(h.base, 'GET', '/api/v1/memory/profile?scope=project:empty', { tok: who })).status, 200);
+ const removed = await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });
+ assert.deepStrictEqual([removed.status, removed.body.deleted], [200, 1]);
+ } finally { await h.stop(); }
+});
+
+t.test('delete removes the row and the profile built from it', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item('a note to remove')] } });
+ const id = made.body.items[0].id;
+ // No items: the profile route writes an empty cache row without spending a model call.
+ const empty = await request(h.base, 'GET', '/api/v1/memory/profile?scope=project:gone', { tok: who });
+ assert.equal(empty.status, 200);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'project:gone'])), 1);
+ const prof = await request(h.base, 'GET', '/api/v1/memory/profile?scope=user', { tok: who });
+ assert.equal(prof.status, 200);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'user'])), 1);
+ const del = await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });
+ assert.deepStrictEqual([del.status, del.body.deleted], [200, 1]);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1 AND id = $2', [`user:${A}`, id])), 0);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_profiles WHERE owner = $1 AND scope = $2', [`user:${A}`, 'user'])), 0);
+ const gone = await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });
+ assert.deepStrictEqual([gone.status, gone.body.code], [404, 'memory.not_found']);
+ } finally { await h.stop(); }
+});
+
+t.test('a scope change re-seals the text, and deleting one source leaves the others', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [
+ item('a cursor note', { source: 'cursor' }),
+ item('a claude note', { source: 'claude-code' }),
+ ] } });
+ const id = made.body.items[0].id;
+ const patched = await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { scope: 'project:demo', text: 'moved note' } });
+ assert.deepStrictEqual([patched.status, patched.body.item.scope, patched.body.item.text], [200, 'project:demo', 'moved note']);
+ const row = await h.db.prepare('SELECT * FROM memory_items WHERE owner = ? AND id = ?').get(`user:${A}`, id);
+ assert.equal(h.memory.open(row.text_enc, h.memory.aadFor(`user:${A}`, id, 'project:demo')), 'moved note');
+ assert.throws(() => h.memory.open(row.text_enc, h.memory.aadFor(`user:${A}`, id, 'user')), /does not open/);
+ const searched = await request(h.base, 'GET', `/api/v1/memory/items?q=moved`, { tok: who });
+ assert.deepStrictEqual([searched.status, searched.body.items.length], [200, 1]);
+ const removed = await request(h.base, 'DELETE', '/api/v1/memory/items?source=cursor', { tok: who });
+ assert.deepStrictEqual([removed.status, removed.body.deleted], [200, 1]);
+ const left = await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });
+ assert.deepStrictEqual(left.body.items.map((i) => i.source), ['claude-code']);
+ } finally { await h.stop(); }
+});
+
+t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and passes noLooks', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ let now = Date.parse('2026-10-30T09:00:00.000Z');
+ let calls = 0;
+ const m = createMemory({ db: h.db, config: h.config, clock: { now: () => now }, log: h.log || console,
+ buildProfile: async ({ items }) => { calls++; return `A shirtless man likes ${items.length} notes. He is a person.`; } });
+ const req = { principal: { kind: 'user', subject: { type: 'user', id: A } }, query: { scope: 'user' }, body: {}, ov: {} };
+ const owner = `user:${A}`;
+ await m.batch({ principal: req.principal, body: { items: [item('one note'), item('two notes')] } }, owner);
+ const first = await m.profile(req, owner);
+ assert.equal(calls, 1);
+ assert.ok(!first.profile.text.includes('shirtless') && !first.profile.text.includes('man'), `noLooks did not run: ${first.profile.text}`);
+ assert.equal(first.profile.text.includes('[redacted]'), false);
+ const second = await m.profile(req, owner);
+ assert.equal(calls, 1, 'the same items_hash is served from the cache');
+ assert.equal(second.profile.built_at, first.profile.built_at);
+
+ await m.batch({ principal: req.principal, body: { items: [item('a third note')] } }, owner);
+ const withinHour = await m.profile(req, owner);
+ assert.equal(calls, 1, 'a changed hash within the hour waits');
+ assert.equal(withinHour.profile.built_at, first.profile.built_at);
+
+ now += 61 * 60 * 1000;
+ const rebuilt = await m.profile(req, owner);
+ assert.equal(calls, 2);
+ assert.notEqual(rebuilt.profile.items_hash, first.profile.items_hash);
+ assert.notEqual(rebuilt.profile.built_at, first.profile.built_at);
+ } finally { await h.stop(); }
+});
+
+t.test('account data: the table guard passes, the export is decrypted, and a deletion erases the person\'s memory', async () => {
+ const stub = await startNetworkStub();
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
+ try {
+ const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
+ assert.deepStrictEqual(missing, []);
+ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('Ada exports this note')] } });
+ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(B), body: { items: [item('Bo keeps this note')] } });
+ const deliver = async (event) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));
+ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the memory export part was pushed');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.ok(files['memory-items.json'].some((i) => i.text === 'Ada exports this note'));
+ assert.ok(!JSON.stringify(part.body).includes('Bo keeps this note'), 'nobody else\'s memory');
+ assert.ok(!JSON.stringify(part.body).includes('m1:'), 'the export is decrypted, not sealed');
+
+ const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
+ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${A}`])), 0);
+ assert.equal(Number(await h.db.value('SELECT count(*)::int FROM memory_items WHERE owner = $1', [`user:${B}`])), 1);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('no item text reaches the logger during a full flow', async () => {
+ const lines = [];
+ const log = { log: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY }, log });
+ try {
+ const who = userToken(A);
+ const marker = 'a-very-distinctive-note-that-must-not-be-logged';
+ const secret = ['correct', 'horse', 'battery', 'staple'].join(' ');
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [item(marker), item(`password: "${secret}"`)] } });
+ assert.equal(made.status, 200);
+ const id = made.body.items[0].id;
+ await request(h.base, 'GET', '/api/v1/memory/items', { tok: who });
+ await request(h.base, 'GET', `/api/v1/memory/items/${id}`, { tok: who });
+ await request(h.base, 'GET', `/api/v1/memory/search?q=${encodeURIComponent(marker)}&scopes=user&on_behalf_of=${A}`, { tok: serviceRead({ memory_scopes: ['user'] }) });
+ await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: who, body: { text: `${marker} edited` } });
+ await request(h.base, 'DELETE', `/api/v1/memory/items/${id}`, { tok: who });
+ const all = lines.join('\n');
+ assert.ok(!all.includes(marker), `the logger saw the item text: ${all}`);
+ assert.ok(!all.includes(secret), 'the logger saw the secret');
+ assert.ok(!all.includes(['correct', 'horse'].join(' ')), 'the logger saw part of the secret');
+ } finally { await h.stop(); }
+});
+
+t.run();
@@ -20,7 +20,7 @@ function mountOf(layer) {
@@ -20,7 +20,7 @@ function mountOf(layer) {
}
}
/** ":param" as OpenAPI writes it, "{param}". */
/** ":param" as OpenAPI writes it, "{param}". */
-const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
+const templated = (p) => String(p).replace(/\\:/g, '\u0000').replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}').replace(/\u0000/g, ':');
/** Every "METHOD /path" the app answers, routers included. */
/** Every "METHOD /path" the app answers, routers included. */
function mounted(app) {
function mounted(app) {