openvibers/openvibe.ai Commits

Memory M2b: the person's own read log (GET /api/v1/memory/reads) (#212)

OP OpenVibers committed 14 days ago · 6a4f1ad4ff · 3 files, +75 −1

Changes

ddiff --git a/server/memory/index.js b/server/memory/index.js
iindex 7c402bd..eae1a4b 100644
---- a/server/memory/index.js
++++ b/server/memory/index.js
@@@ -17,6 +17,7 @@
* DELETE /api/v1/memory/items?source= everything from one source
* POST /api/v1/memory/items/:id/split undo a merge: the item and the one it swallowed go apart again
* POST /api/v1/memory/items/:id/settle the person settles a conflict: { keep: <id> } keeps one and archives the other
++ * GET /api/v1/memory/reads the person's own read log: who read, for what, how many notes (never ids, never text)
* 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
@@@ -835,6 +836,32 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
assertConfigured();
return await profileFor(owner, scope);
}
++ /**
++ * The person's own read log (M2b): who read their memory, for what, in which scopes, how many notes and when.
++ * It never carries the note ids and never the text: only the count of what was read. A service token may not
++ * read it (it is the person's own audit), so the route is person-only.
++ */
++ async function reads(req, owner) {
++ assertConfigured();
++ const limit = Math.max(1, Math.min(Number(req.query.limit) || 50, 200));
++ const rows = await q('SELECT id, actor, purpose, scopes, item_ids, created_at FROM memory_reads WHERE owner = $1 ORDER BY created_at DESC LIMIT $2').all(owner, limit);
++ const list = rows.map((r) => {
++ const scopes = [];
++ try { const parsed = JSON.parse(r.scopes || '[]'); if (Array.isArray(parsed)) for (const s of parsed) scopes.push(String(s).slice(0, 80)); } catch { /* a row without scopes */ }
++ let count = 0;
++ try { const parsed = JSON.parse(r.item_ids || '[]'); if (Array.isArray(parsed)) count = parsed.length; } catch { /* a row without ids */ }
++ return {
++ id: String(r.id || ''),
++ actor: String(r.actor || '').slice(0, 120),
++ purpose: r.purpose == null ? null : String(r.purpose).slice(0, 200),
++ scopes: scopes.slice(0, 10),
++ count,
++ created_at: r.created_at,
++ };
++ });
++ return { reads: list, count: list.length };
++ }
++
/** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */
async function exportFor(subject) {
assertConfigured();
@@@ -851,7 +878,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
}
return {
-- assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor,
++ assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, reads,
split, settle, aadFor, macOf, seal, open,
// The store's own maintenance (server/memory/jobs.js) drives the store through these.
ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,
@@@ -918,6 +945,9 @@ function memoryRouter({ memory, auth, config, clock = { now: () => Date.now() },
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))));
++ // M2b: the person's own read log (who read their memory, and how much of it). Person-only, like the other
++ // browsing routes: a service has no business in the person's audit.
++ r.get('/api/v1/memory/reads', guard(READ_CAP), personOnly, run(async (req) => await memory.reads(req, ownerOf(req))));
r.get('/api/v1/memory/search', guard(READ_CAP), run(async (req) => await memory.search(req, ownerOf(req))));
// M2c: undo a merge, and settle a conflict. Both are the person's own: a service may only search and suggest.
r.post('/api/v1/memory/items/:id/split', guard(WRITE_CAP), personOnly, run(async (req) => await memory.split(req, ownerOf(req), req.params.id)));
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 5e25a99..5b50b2b 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -135,6 +135,12 @@ route('get', '/memory/profile', {
parameters: [query('scope', str()), query('on_behalf_of', str())],
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
});
++route('get', '/memory/reads', {
++ operationId: 'listMemoryReads', summary: 'The person’s own read log', tags: ['memory'], security: memorySecurity,
++ description: 'ai.memory.read. The person’s own token only: who read their memory, for what, in which scopes, how many notes and when. The answer never carries the note ids and never the text, only the count of what was read.',
++ parameters: [query('limit', integer({ minimum: 1, maximum: 200 }), 'How many reads at most (50 by default)')],
++ responses: { 200: json('The reads', 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.',
ddiff --git a/test/memory.test.js b/test/memory.test.js
iindex 737a875..d62e2a4 100644
---- a/test/memory.test.js
++++ b/test/memory.test.js
@@@ -226,6 +226,44 @@ t.test('a service may only search and suggest: every other memory route is perso
} finally { await h.stop(); }
});
++t.test('the read log belongs to the person: who read, for what, how many notes, and never the ids or the text', async () => {
++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
++ try {
++ // Two people, one agent read for each: the log is the person's, never a shared one.
++ for (const who of [A, B]) {
++ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(who), body: { items: [
++ item(`a note of ${who}`, { source: 'claude-code' }),
++ ] } });
++ await request(h.base, 'GET', `/api/v1/memory/search?q=note&scopes=user&purpose=task-for-${who}&on_behalf_of=${who}`, { tok: serviceRead({ memory_scopes: ['user'] }) });
++ }
++ const mine = await request(h.base, 'GET', '/api/v1/memory/reads?limit=50', { tok: userToken(A) });
++ assert.deepStrictEqual([mine.status, mine.body.reads.length], [200, 1]);
++ const row = mine.body.reads[0];
++ assert.equal(row.actor, 'svc:codes');
++ assert.equal(row.purpose, 'task-for-' + A);
++ assert.deepStrictEqual(row.scopes, ['user']);
++ assert.equal(row.count, 1);
++ assert.ok(row.created_at, 'the read carries its time');
++ // The answer never carries the note ids and never the text.
++ assert.ok(!mine.text.includes('mem_'), 'the read log carries no item ids');
++ assert.ok(!mine.text.includes('a note of'), 'the read log carries no text');
++ assert.ok(!('item_ids' in row) && !('items' in row));
++
++ // Another person sees nothing of this one's reads.
++ const other = await request(h.base, 'GET', '/api/v1/memory/reads', { tok: userToken(B) });
++ assert.deepStrictEqual([other.status, other.body.reads.length], [200, 1]);
++ assert.equal(other.body.reads[0].purpose, 'task-for-' + B);
++
++ // A service may not read the person's audit.
++ const denied = await request(h.base, 'GET', `/api/v1/memory/reads?on_behalf_of=${A}`, { tok: serviceRead({ memory_scopes: ['user'] }) });
++ assert.deepStrictEqual([denied.status, denied.body.code], [403, 'capability.denied']);
++
++ // The limit is the person's: at most what was asked for.
++ const capped = await request(h.base, 'GET', '/api/v1/memory/reads?limit=0', { tok: userToken(A) });
++ assert.equal(capped.status, 200);
++ } 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 {