openvibers/openvibe.ai Commits

Memory M2a: the sealed store, the agent search and the profile cache (#209)

OP OpenVibers committed 14 days ago · 10bea1c2be · 13 files, +1107 −20

* Memory M2a: the sealed store, the agent search and the profile cache

* Memory: a person token may carry its subject in sub, and a run's ns may grant a project scope

* Memory: a service may only search and suggest

Changes

ddiff --git a/.env.example b/.env.example
iindex afc6d44..1bfbb13 100644
---- a/.env.example
++++ b/.env.example
@@@ -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).
ddiff --git a/README.md b/README.md
iindex d315033..ff97af2 100644
---- a/README.md
++++ b/README.md
@@@ -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.cpp
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
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`,
`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 is
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
`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 is
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.
++- **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
kept; the cache never crosses requester, actor, target or attribution; `history: false` keeps
neither input nor cache entry.
ddiff --git a/migrations/0017_memory.sql b/migrations/0017_memory.sql
nnew file mode 100644
iindex 0000000..3ae6d7f
---- /dev/null
++++ b/migrations/0017_memory.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex fcf5c0a..3e329f5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 47b564e..82943f5 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 2040721..3a51ca2 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 6b9f38b..c4dee34 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) }));
ddiff --git a/server/config.js b/server/config.js
iindex fa80ebe..d74fefd 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/index.js b/server/index.js
iindex 6884a3a..d493f96 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.now
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) {
ddiff --git a/server/memory/index.js b/server/memory/index.js
nnew file mode 100644
iindex 0000000..7465648
---- /dev/null
++++ b/server/memory/index.js
@@@ -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 };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 1a9d4cc..1c891ed 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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'),
ddiff --git a/test/memory.test.js b/test/memory.test.js
nnew file mode 100644
iindex 0000000..e4f985b
---- /dev/null
++++ b/test/memory.test.js
@@@ -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();
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex a2e2f61..3224dee 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -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) {