openvibers/openvibe.ai Commits
Memory M2b: the person's own read log (GET /api/v1/memory/reads) (#212)
Changes
@@@ -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 = conassertConfigured();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)));@@@ -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.',@@@ -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 {
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
* DELETE /api/v1/memory/items?source= everything from one source
* 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/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
* 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,
* 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
* 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
* 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
@@ -835,6 +836,32 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
assertConfigured();
assertConfigured();
return await profileFor(owner, scope);
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). */
/** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */
async function exportFor(subject) {
async function exportFor(subject) {
assertConfigured();
assertConfigured();
@@ -851,7 +878,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -851,7 +878,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
}
}
return {
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,
split, settle, aadFor, macOf, seal, open,
// The store's own maintenance (server/memory/jobs.js) drives the store through these.
// The store's own maintenance (server/memory/jobs.js) drives the store through these.
ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,
ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,
@@ -918,6 +945,9 @@ function memoryRouter({ memory, auth, config, clock = { now: () => Date.now() },
@@ -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/: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.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/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))));
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.
// 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)));
r.post('/api/v1/memory/items/:id/split', guard(WRITE_CAP), personOnly, run(async (req) => await memory.split(req, ownerOf(req), req.params.id)));
@@ -135,6 +135,12 @@ route('get', '/memory/profile', {
@@ -135,6 +135,12 @@ route('get', '/memory/profile', {
parameters: [query('scope', str()), query('on_behalf_of', str())],
parameters: [query('scope', str()), query('on_behalf_of', str())],
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
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', {
route('get', '/memory/search', {
operationId: 'searchMemoryForAgent', summary: 'An agent reads active memory in its granted scopes', tags: ['memory'], security: memorySecurity,
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.',
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.',
@@ -226,6 +226,44 @@ t.test('a service may only search and suggest: every other memory route is perso
@@ -226,6 +226,44 @@ t.test('a service may only search and suggest: every other memory route is perso
} finally { await h.stop(); }
} 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 () => {
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 } });
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
try {
try {