diff --git a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts index 978079583..9faf67723 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts @@ -618,9 +618,15 @@ export class SpaceRuntimeService { }, }, }); - this.attachLongTermAgentMcpServers(session, space, agent.displayName, sessionId, null, [ - `@${agent.handle}`, - ]); + this.attachLongTermAgentMcpServers( + session, + space, + agent.displayName, + sessionId, + null, + [`@${agent.handle}`], + agent.id + ); return session; } @@ -706,7 +712,15 @@ export class SpaceRuntimeService { await session.resetQuery({ restartQuery: true }); } if (created || this.missingLongTermAgentMcpServers(session)) { - this.attachLongTermAgentMcpServers(session, space, agent.name, sessionId, agent); + this.attachLongTermAgentMcpServers( + session, + space, + agent.name, + sessionId, + agent, + undefined, + agent.id + ); } return session; } @@ -740,7 +754,15 @@ export class SpaceRuntimeService { } const agentName = session.metadata.promptProvenance?.agentName ?? persistedAgent?.name ?? 'Space Agent'; - this.attachLongTermAgentMcpServers(agentSession, space, agentName, session.id, persistedAgent); + this.attachLongTermAgentMcpServers( + agentSession, + space, + agentName, + session.id, + persistedAgent, + undefined, + agentId + ); agentSession.onMissingMemberSpaceMcpServers = async (_sessionId, missing) => { log.warn( `Long-term Space agent session ${session.id} missing MCP servers [${missing.join(', ')}]; re-attaching space-agent-tools before query start` @@ -762,7 +784,8 @@ export class SpaceRuntimeService { agentName: string, sessionId: string, agent: SpaceWorkerAgent | null, - agentHandleAliases?: string[] + agentHandleAliases?: string[], + ownerAgentId?: string ): void { const mcpServers: Record = { 'space-agent-tools': this.buildLongTermAgentMcpServer( @@ -778,6 +801,8 @@ export class SpaceRuntimeService { spaceId: space.id, memoryRepo: this.config.memoryRepo, mySessionId: sessionId, + // Scope this agent's memory writes/reads to its own private namespace. + ownerAgentId, }) as unknown as McpServerConfig; } if (this.config.dbPath) { diff --git a/packages/daemon/src/lib/space/tools/agent-memory-tools.ts b/packages/daemon/src/lib/space/tools/agent-memory-tools.ts index 81bd0f9bd..fe1660744 100644 --- a/packages/daemon/src/lib/space/tools/agent-memory-tools.ts +++ b/packages/daemon/src/lib/space/tools/agent-memory-tools.ts @@ -1,6 +1,10 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; -import type { AgentMemoryRepository } from '../../../storage/repositories/agent-memory-repository'; +import type { + AgentMemoryRepository, + AgentMemoryScope, + AgentMemoryScopeFilter, +} from '../../../storage/repositories/agent-memory-repository'; import type { ToolResult } from './tool-result'; import { jsonResult } from './tool-result'; @@ -11,29 +15,61 @@ const MemoryWriteSchema = z.object({ .min(1) .describe('Fact, convention, decision, or project knowledge to persist.'), tags: z.array(z.string()).optional().describe('Keyword tags that improve retrieval.'), + scope: z + .enum(['agent', 'space']) + .optional() + .describe( + "Where to store this memory. 'agent' writes to this agent's private namespace (default for long-horizon agents); 'space' writes to the shared Space pool visible to everyone." + ), }); -const MemorySearchSchema = z.object({ +export const MemorySearchSchema = z.object({ query: z.string().min(1).describe('Natural language query or code identifier.'), limit: z.number().int().min(1).max(20).default(10), + scope: z + .enum(['mine', 'space']) + .optional() + .describe( + "Which memories to search. Omit for this agent's private memories plus the shared Space pool. 'mine' = only this agent's private memories, 'space' = only the shared pool." + ), }); -const MemoryReadSchema = z.object({ +export const MemoryReadSchema = z.object({ key: z.string().min(1).max(200).describe('Memory key to read.'), + scope: z + .enum(['mine', 'space']) + .optional() + .describe( + "Which namespace to read from. Omit to prefer this agent's private memory, falling back to the shared pool. 'mine' = only this agent's, 'space' = only the shared pool." + ), }); -const MemoryDeleteSchema = z.object({ +export const MemoryDeleteSchema = z.object({ key: z.string().min(1).max(200).describe('Memory key to delete.'), + scope: z + .enum(['mine', 'space']) + .optional() + .describe( + "Which namespace to delete from. Omit to delete this agent's private memory and the shared entry under this key (never another agent's). 'space' = only the shared entry." + ), }); export interface AgentMemoryToolsConfig { spaceId: string; memoryRepo: AgentMemoryRepository; mySessionId?: string; + /** + * The agent id that owns memories written/read through this tool instance. + * Resolved from the calling session's `promptProvenance.agentId` at attach + * time. When set, writes default to the agent's private namespace and + * searches default to "mine + space". Omit for shared-only access (member / + * space_chat / workflow-node sessions with no durable agent identity). + */ + ownerAgentId?: string; } export function createAgentMemoryToolHandlers(config: AgentMemoryToolsConfig) { - const { spaceId, memoryRepo, mySessionId } = config; + const { spaceId, memoryRepo, mySessionId, ownerAgentId } = config; return { async 'memory.write'(args: z.infer): Promise { const memory = memoryRepo.write({ @@ -45,23 +81,37 @@ export function createAgentMemoryToolHandlers(config: AgentMemoryToolsConfig) { // updates instead of clearing them with an explicit empty array. tags: args.tags, createdBySession: mySessionId ?? null, + ownerAgentId, + scope: args.scope as AgentMemoryScope | undefined, }); return jsonResult({ success: true, memory }); }, async 'memory.search'(args: z.infer): Promise { - const results = await memoryRepo.search(spaceId, args.query, args.limit); + const results = await memoryRepo.search(spaceId, args.query, args.limit, { + ownerAgentId, + scope: args.scope as AgentMemoryScopeFilter | undefined, + }); return jsonResult({ success: true, results }); }, async 'memory.read'(args: z.infer): Promise { - const memory = memoryRepo.read(spaceId, args.key); + const memory = memoryRepo.read(spaceId, args.key, { + ownerAgentId, + scope: args.scope as AgentMemoryScopeFilter | undefined, + }); if (!memory) return jsonResult({ success: false, error: `Memory not found: ${args.key}` }); return jsonResult({ success: true, memory }); }, async 'memory.delete'(args: z.infer): Promise { - return jsonResult({ success: true, deleted: memoryRepo.delete(spaceId, args.key) }); + return jsonResult({ + success: true, + deleted: memoryRepo.delete(spaceId, args.key, { + ownerAgentId, + scope: args.scope as AgentMemoryScopeFilter | undefined, + }), + }); }, }; } diff --git a/packages/daemon/src/storage/repositories/agent-memory-repository.ts b/packages/daemon/src/storage/repositories/agent-memory-repository.ts index 302cec36d..b8555d621 100644 --- a/packages/daemon/src/storage/repositories/agent-memory-repository.ts +++ b/packages/daemon/src/storage/repositories/agent-memory-repository.ts @@ -1,9 +1,35 @@ import type { Database as BunDatabase } from 'bun:sqlite'; import type { ReactiveDatabase } from '../reactive-database'; +/** + * The owner/scope of a memory row. + * + * - `space` — shared across the whole Space (the legacy single-pool behavior). + * `ownerAgentId` is null. + * - `agent` — private to one long-horizon agent. `ownerAgentId` is the agent id + * resolved from the writing session's `promptProvenance.agentId`. + */ +export type AgentMemoryScope = 'agent' | 'space'; + +/** + * Read-side scope filter for search/list/read/delete. + * + * - `mine` — only rows owned by the caller's agent (no shared rows). + * - `space` — only shared (space-scoped) rows. + * - `all` — every row in the Space regardless of owner. + * + * When omitted (and `ownerAgentId` is set) the default is "mine + space": the + * caller's private rows plus all shared rows — the natural view for an agent + * that wants its own memory and the common knowledge. + */ +export type AgentMemoryScopeFilter = 'mine' | 'space' | 'all'; + export interface AgentMemoryEntry { key: string; spaceId: string; + /** Agent id that owns this row, or null when space-scoped (shared). */ + ownerAgentId: string | null; + scope: AgentMemoryScope; content: string; tags: string[]; createdBySession: string | null; @@ -47,6 +73,8 @@ interface AgentMemoryRow { id: number; key: string; space_id: string; + owner_agent_id: string; + scope: AgentMemoryScope; content: string; tags: string; created_by_session: string | null; @@ -103,6 +131,10 @@ export class AgentMemoryRepository { content: string; tags?: string[]; createdBySession?: string | null; + /** Agent id that owns this row. Omit/null for a shared (space-scoped) row. */ + ownerAgentId?: string | null; + /** Force the write target. Defaults to 'agent' when an owner is given. */ + scope?: AgentMemoryScope; }): AgentMemoryEntry { const key = normalizeKey(params.key); const content = normalizeContent(params.content); @@ -111,15 +143,22 @@ export class AgentMemoryRepository { const now = Date.now(); const embeddingToken = crypto.randomUUID(); - // On conflict (existing row): preserve `tags` when caller did not supply them - // and never overwrite `created_by_session` so provenance stays with the - // original author. + // Resolve the namespace. An explicit scope='space' writes to the shared pool + // even when an owner id is supplied (an agent contributing common knowledge). + // Otherwise an owner id makes the row agent-scoped (private). + const ownerInput = (params.ownerAgentId ?? '').trim(); + const ownerAgentId = params.scope === 'space' ? '' : ownerInput; + const scope: AgentMemoryScope = ownerAgentId ? 'agent' : 'space'; + + // On conflict (same space + owner + key): preserve `tags` when caller did + // not supply them and never overwrite `created_by_session` or the owner/scope + // so provenance and namespace stay with the original author. const row = this.db .prepare( `INSERT INTO space_agent_memory - (key, space_id, content, tags, created_by_session, created_at, updated_at, access_count, last_accessed_at, embedding_status, embedding_model, embedding_updated_at, embedding_error, embedding_revision, embedding_token) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, 'pending', NULL, NULL, NULL, 1, ?) - ON CONFLICT(space_id, key) DO UPDATE SET + (key, space_id, owner_agent_id, scope, content, tags, created_by_session, created_at, updated_at, access_count, last_accessed_at, embedding_status, embedding_model, embedding_updated_at, embedding_error, embedding_revision, embedding_token) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, 'pending', NULL, NULL, NULL, 1, ?) + ON CONFLICT(space_id, owner_agent_id, key) DO UPDATE SET content = excluded.content, tags = CASE WHEN ? = 1 THEN excluded.tags ELSE space_agent_memory.tags END, updated_at = excluded.updated_at, @@ -134,6 +173,8 @@ export class AgentMemoryRepository { .get( key, params.spaceId, + ownerAgentId, + scope, content, serializeTags(tags), params.createdBySession ?? null, @@ -151,28 +192,122 @@ export class AgentMemoryRepository { read( spaceId: string, key: string, - options?: { recordAccess?: boolean } + options?: { + ownerAgentId?: string | null; + scope?: AgentMemoryScopeFilter; + recordAccess?: boolean; + } ): AgentMemoryEntry | null { const normalizedKey = normalizeKey(key); - const row = this.db - .prepare(`SELECT * FROM space_agent_memory WHERE space_id = ? AND key = ?`) - .get(spaceId, normalizedKey) as AgentMemoryRow | undefined; + const owner = (options?.ownerAgentId ?? '').trim(); + const scope = options?.scope; + + let row: AgentMemoryRow | undefined; + if (scope === 'mine') { + // Only the caller's own row; never falls back to shared. + row = owner ? this.readRow(spaceId, normalizedKey, owner) : undefined; + } else if (scope === 'space' || (!scope && !owner)) { + // Shared pool only (also the default when there is no owner context — + // e.g. RPC/UI callers that have no agent attribution). + row = this.readRow(spaceId, normalizedKey, ''); + } else if (scope === 'all') { + // Trusted callers only (NOT exposed through the agent MCP tool — the tool + // schema restricts to 'mine' | 'space'). Returns any row under this key, + // preferring the caller's own, then shared, then another owner's private + // row. Consistent with search/list/delete 'all'. + row = this.readAnyRow(spaceId, normalizedKey, owner); + } else { + // Default (mine + space): prefer the caller's own row, then fall back to + // the shared row. + row = (owner && this.readRow(spaceId, normalizedKey, owner)) || undefined; + row = row ?? this.readRow(spaceId, normalizedKey, ''); + } + if (!row) return null; - if (options?.recordAccess !== false) this.recordAccess(spaceId, normalizedKey); + if (options?.recordAccess !== false) this.recordAccessById(row.id); return rowToEntry(row); } - delete(spaceId: string, key: string): boolean { + private readAnyRow( + spaceId: string, + normalizedKey: string, + ownerAgentId: string + ): AgentMemoryRow | undefined { + return this.db + .prepare( + `SELECT * FROM space_agent_memory + WHERE space_id = ? AND key = ? + ORDER BY CASE + WHEN owner_agent_id = ? THEN 0 + WHEN owner_agent_id = '' THEN 1 + ELSE 2 + END, id ASC + LIMIT 1` + ) + .get(spaceId, normalizedKey, ownerAgentId) as AgentMemoryRow | undefined; + } + + private readRow( + spaceId: string, + normalizedKey: string, + ownerAgentId: string + ): AgentMemoryRow | undefined { + return this.db + .prepare( + `SELECT * FROM space_agent_memory WHERE space_id = ? AND key = ? AND owner_agent_id = ?` + ) + .get(spaceId, normalizedKey, ownerAgentId) as AgentMemoryRow | undefined; + } + + delete( + spaceId: string, + key: string, + options?: { ownerAgentId?: string | null; scope?: AgentMemoryScopeFilter } + ): boolean { const normalizedKey = normalizeKey(key); + const owner = (options?.ownerAgentId ?? '').trim(); + const scope = options?.scope; + + // Resolve which owner namespace(s) a delete may touch. A caller never + // deletes another agent's private row: 'mine' targets only the caller's row, + // the default (mine + space) targets the caller's row and the shared row, + // and 'space'/no-owner targets only the shared row. + const owners: string[] = []; + if (scope === 'all') { + // Administrators only — wipe every row under this key across owners. + const all = this.db + .prepare( + `SELECT DISTINCT owner_agent_id AS o FROM space_agent_memory WHERE space_id = ? AND key = ?` + ) + .all(spaceId, normalizedKey) as Array<{ o: string }>; + owners.push(...all.map((row) => row.o)); + } else if (scope === 'mine') { + if (owner) owners.push(owner); + } else if (scope === 'space' || !owner) { + owners.push(''); + } else { + // default: mine + space + owners.push(owner, ''); + } + + if (owners.length === 0) return false; + const placeholders = owners.map(() => '?').join(', '); const result = this.db - .prepare(`DELETE FROM space_agent_memory WHERE space_id = ? AND key = ?`) - .run(spaceId, normalizedKey); + .prepare( + `DELETE FROM space_agent_memory WHERE space_id = ? AND key = ? AND owner_agent_id IN (${placeholders})` + ) + .run(spaceId, normalizedKey, ...owners); if (result.changes > 0) this.reactiveDb?.notifyChange('space_agent_memory'); return result.changes > 0; } - async search(spaceId: string, query: string, limit = 10): Promise { - return (await this.searchWithOptions(spaceId, query, { limit })).map((row) => ({ + async search( + spaceId: string, + query: string, + limit = 10, + options?: { ownerAgentId?: string | null; scope?: AgentMemoryScopeFilter } + ): Promise { + return (await this.searchWithOptions(spaceId, query, { limit, ...options })).map((row) => ({ memory: rowToEntry(row), rank: row.rank, })); @@ -180,37 +315,63 @@ export class AgentMemoryRepository { async list( spaceId: string, - options?: { query?: string; limit?: number; offset?: number } + options?: { + query?: string; + limit?: number; + offset?: number; + ownerAgentId?: string | null; + scope?: AgentMemoryScopeFilter; + } ): Promise { const limit = normalizeLimit(options?.limit ?? 50, 100); const offset = Math.max(0, Math.trunc(options?.offset ?? 0)); const query = options?.query?.trim(); if (query) { - return (await this.searchWithOptions(spaceId, query, { limit, offset, maxLimit: 100 })).map( - rowToEntry - ); + return ( + await this.searchWithOptions(spaceId, query, { + limit, + offset, + maxLimit: 100, + ownerAgentId: options?.ownerAgentId, + scope: options?.scope, + }) + ).map(rowToEntry); } + const ownerFilter = buildOwnerFilter(options?.ownerAgentId, options?.scope, 'm'); const rows = this.db .prepare( - `SELECT * FROM space_agent_memory - WHERE space_id = ? - ORDER BY updated_at DESC, key ASC + `SELECT m.* FROM space_agent_memory m + WHERE m.space_id = ? + ${ownerFilter.clause ? `AND ${ownerFilter.clause}` : ''} + ORDER BY m.updated_at DESC, m.key ASC LIMIT ? OFFSET ?` ) - .all(spaceId, limit, offset) as AgentMemoryRow[]; + .all(spaceId, ...ownerFilter.params, limit, offset) as AgentMemoryRow[]; return rows.map(rowToEntry); } - recordAccess(spaceId: string, key: string): void { + recordAccess(spaceId: string, key: string, ownerAgentId?: string | null): void { + const owner = (ownerAgentId ?? '').trim(); + this.db + .prepare( + `UPDATE space_agent_memory + SET access_count = access_count + 1, last_accessed_at = ? + WHERE space_id = ? AND key = ? AND owner_agent_id = ?` + ) + .run(Date.now(), spaceId, normalizeKey(key), owner); + this.reactiveDb?.notifyChange('space_agent_memory'); + } + + private recordAccessById(id: number): void { this.db .prepare( `UPDATE space_agent_memory SET access_count = access_count + 1, last_accessed_at = ? - WHERE space_id = ? AND key = ?` + WHERE id = ?` ) - .run(Date.now(), spaceId, normalizeKey(key)); + .run(Date.now(), id); this.reactiveDb?.notifyChange('space_agent_memory'); } @@ -274,14 +435,32 @@ export class AgentMemoryRepository { } private mergeDuplicateMemories(spaceId: string, threshold: number): number { + // Duplicate merging is scoped per owner so one agent's private memory is + // never folded into another's (or into the shared pool). Each owner + // namespace — including the shared '' namespace — is deduped independently. + const owners = this.db + .prepare(`SELECT DISTINCT owner_agent_id AS o FROM space_agent_memory WHERE space_id = ?`) + .all(spaceId) as Array<{ o: string }>; + let merged = 0; + for (const { o } of owners) { + merged += this.mergeDuplicateMemoriesForOwner(spaceId, o, threshold); + } + return merged; + } + + private mergeDuplicateMemoriesForOwner( + spaceId: string, + ownerAgentId: string, + threshold: number + ): number { const initialRows = this.db .prepare( `SELECT * FROM space_agent_memory - WHERE space_id = ? + WHERE space_id = ? AND owner_agent_id = ? ORDER BY updated_at DESC, key ASC LIMIT ?` ) - .all(spaceId, DUPLICATE_COMPARISON_LIMIT) as AgentMemoryRow[]; + .all(spaceId, ownerAgentId, DUPLICATE_COMPARISON_LIMIT) as AgentMemoryRow[]; const deletedIds = new Set(); const touchedIds = new Set(); let merged = 0; @@ -393,6 +572,10 @@ export class AgentMemoryRepository { ) .all(spaceId) as AgentMemoryRow[] ) + // Core memories are the Space's shared "common knowledge". Only shared + // (space-scoped) rows are eligible — an agent's private hot memory must + // not surface in the shared core ranking. + .filter((row) => row.owner_agent_id === '') .map((row) => ({ row, score: coreMemoryScore(row) })) .sort( (left, right) => @@ -417,27 +600,36 @@ export class AgentMemoryRepository { private async searchWithOptions( spaceId: string, query: string, - options?: { limit?: number; offset?: number; maxLimit?: number } + options?: { + limit?: number; + offset?: number; + maxLimit?: number; + ownerAgentId?: string | null; + scope?: AgentMemoryScopeFilter; + } ): Promise { const ftsQuery = buildFtsQuery(query); const limit = normalizeLimit(options?.limit ?? 10, options?.maxLimit ?? 20); const offset = Math.max(0, Math.trunc(options?.offset ?? 0)); const candidateLimit = options?.maxLimit ?? VECTOR_CANDIDATE_LIMIT; const poolLimit = Math.max(candidateLimit, limit + offset); + const ownerFilter = buildOwnerFilter(options?.ownerAgentId, options?.scope); - const ftsRows = ftsQuery ? this.searchFts(spaceId, ftsQuery, poolLimit, 0) : []; - const vectorRows = await this.searchVector(spaceId, query, poolLimit); + const ftsRows = ftsQuery ? this.searchFts(spaceId, ftsQuery, poolLimit, 0, ownerFilter) : []; + const vectorRows = await this.searchVector(spaceId, query, poolLimit, ownerFilter); const rows = mergeRankedRows(ftsRows, vectorRows).slice(offset, offset + limit); if (rows.length > 0) { const now = Date.now(); + // Bump by row id so access telemetry hits the exact namespace-scoped row + // (multiple rows can now share a key across owners). const bump = this.db.prepare( `UPDATE space_agent_memory SET access_count = access_count + 1, last_accessed_at = ? - WHERE space_id = ? AND key = ?` + WHERE id = ?` ); const updateAccess = this.db.transaction((items: AgentMemorySearchRow[]) => { - for (const row of items) bump.run(now, row.space_id, row.key); + for (const row of items) bump.run(now, row.id); }); updateAccess(rows); this.reactiveDb?.notifyChange('space_agent_memory'); @@ -450,7 +642,8 @@ export class AgentMemoryRepository { spaceId: string, ftsQuery: string, limit: number, - offset: number + offset: number, + ownerFilter: OwnerFilter ): AgentMemorySearchRow[] { return this.db .prepare( @@ -458,13 +651,19 @@ export class AgentMemoryRepository { FROM space_agent_memory_fts JOIN space_agent_memory m ON m.id = space_agent_memory_fts.rowid WHERE space_agent_memory_fts MATCH ? AND m.space_id = ? + ${ownerFilter.clause ? `AND ${ownerFilter.clause}` : ''} ORDER BY rank ASC, m.updated_at DESC, m.key ASC LIMIT ? OFFSET ?` ) - .all(ftsQuery, spaceId, limit, offset) as AgentMemorySearchRow[]; + .all(ftsQuery, spaceId, ...ownerFilter.params, limit, offset) as AgentMemorySearchRow[]; } - private async searchVector(spaceId: string, query: string, limit: number): Promise { + private async searchVector( + spaceId: string, + query: string, + limit: number, + ownerFilter: OwnerFilter + ): Promise { const queryVector = await this.embedText(query, 'query', { fallbackToNull: true }); if (!queryVector || !this.embedder) return []; @@ -476,9 +675,15 @@ export class AgentMemoryRepository { WHERE m.space_id = ? AND m.embedding_status = 'ready' AND v.model = ? - AND v.dimensions = ?` + and v.dimensions = ? + ${ownerFilter.clause ? `AND ${ownerFilter.clause}` : ''}` ) - .all(spaceId, this.embedder.model, this.embedder.dimensions) as AgentMemoryVectorRow[]; + .all( + spaceId, + this.embedder.model, + this.embedder.dimensions, + ...ownerFilter.params + ) as AgentMemoryVectorRow[]; return rows .map((row) => { @@ -669,6 +874,10 @@ function rowToEntry(row: AgentMemoryRow): AgentMemoryEntry { return { key: row.key, spaceId: row.space_id, + // The empty string is the space-scoped sentinel at the storage layer; expose + // it as null in the public entry type. + ownerAgentId: row.owner_agent_id || null, + scope: row.scope, content: row.content, tags: parseTags(row.tags), createdBySession: row.created_by_session, @@ -729,6 +938,42 @@ function coreMemoryScore(row: AgentMemoryRow): number { return row.access_count / (1 + ageDays); } +interface OwnerFilter { + /** SQL fragment referencing the table alias, or '' for no filtering. */ + clause: string; + params: string[]; +} + +/** + * Build a `WHERE` fragment that selects which owner namespaces a read may see. + * + * - `scope='all'` → every row. + * - `scope='space'` (or no owner context) → shared rows only (`owner = ''`). + * - `scope='mine'` → only the caller's rows (nothing when there is no owner). + * - default → caller's rows + shared rows (the natural agent view). + * + * The empty-string sentinel keeps every branch a plain equality/IN predicate — + * no IS NULL special-casing. `alias` lets callers target `m.owner_agent_id` or + * the unqualified column. + */ +function buildOwnerFilter( + ownerAgentId: string | null | undefined, + scope: AgentMemoryScopeFilter | undefined, + alias = 'm' +): OwnerFilter { + const col = `${alias}.owner_agent_id`; + const owner = (ownerAgentId ?? '').trim(); + if (scope === 'all') return { clause: '', params: [] }; + if (scope === 'mine') { + if (!owner) return { clause: '0', params: [] }; + return { clause: `${col} = ?`, params: [owner] }; + } + if (scope === 'space') return { clause: `${col} = ''`, params: [] }; + // default (mine + space) + if (!owner) return { clause: `${col} = ''`, params: [] }; + return { clause: `${col} IN (?, '')`, params: [owner] }; +} + function mergeRankedRows( ftsRows: AgentMemorySearchRow[], vectorRows: RankedRow[] diff --git a/packages/daemon/src/storage/schema/index.ts b/packages/daemon/src/storage/schema/index.ts index 5cbf662cc..28a0e4174 100644 --- a/packages/daemon/src/storage/schema/index.ts +++ b/packages/daemon/src/storage/schema/index.ts @@ -801,11 +801,24 @@ function createSpaceAgentInboxTables(db: BunDatabase): void { } function createAgentMemoryTables(db: BunDatabase): void { + // Per-agent namespacing: `owner_agent_id` is the agent whose private + // namespace a row belongs to. The empty string is the space-scoped (shared) + // sentinel — using '' rather than NULL keeps the composite UNIQUE constraint + // enforceable, because SQLite treats NULLs as distinct and would otherwise + // allow duplicate shared rows. `owner_agent_id` is an opaque, + // application-resolved key (the writing session's `promptProvenance.agentId`) + // and is intentionally NOT a foreign key: provenance can reference either + // `space_agents` or `space_long_horizon_agents`, and SQLite cannot express a + // FK to "one of two tables." The CHECK ties `scope` to owner presence so the + // two columns never disagree. db.exec(` CREATE TABLE IF NOT EXISTS space_agent_memory ( id INTEGER PRIMARY KEY, key TEXT NOT NULL, space_id TEXT NOT NULL, + owner_agent_id TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'space' + CHECK(scope IN ('agent', 'space')), content TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '', created_by_session TEXT, @@ -820,7 +833,11 @@ function createAgentMemoryTables(db: BunDatabase): void { embedding_error TEXT, embedding_revision INTEGER NOT NULL DEFAULT 0, embedding_token TEXT NOT NULL DEFAULT '', - UNIQUE(space_id, key), + UNIQUE(space_id, owner_agent_id, key), + CHECK( + (scope = 'agent' AND owner_agent_id != '') + OR (scope = 'space' AND owner_agent_id = '') + ), FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE ) `); @@ -929,6 +946,12 @@ function createIndexes(db: BunDatabase): void { db.exec( `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_embedding_status ON space_agent_memory(space_id, embedding_status)` ); + // Covers owner-scoped lookups (a caller reading its own namespace, or + // consolidation grouping rows by owner). The leading space_id keeps it + // useful for the space-first query plans used everywhere else. + db.exec( + `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_owner ON space_agent_memory(space_id, owner_agent_id)` + ); db.exec( `CREATE INDEX IF NOT EXISTS idx_space_agent_core_memory_rank ON space_agent_core_memory(space_id, rank)` ); diff --git a/packages/daemon/src/storage/schema/migrations.ts b/packages/daemon/src/storage/schema/migrations.ts index 7e41ee8a4..e391c0f5e 100644 --- a/packages/daemon/src/storage/schema/migrations.ts +++ b/packages/daemon/src/storage/schema/migrations.ts @@ -733,6 +733,11 @@ export function runMigrations(db: BunDatabase, createBackup: () => void): void { // evolution-finding domain to their rebranded identifiers so persisted rows match // the code after the HyperNeo rename. run(migrationMarkerKey(162), () => runMigration162(db)); + + // Migration 163: Per-agent memory namespacing — add `owner_agent_id` + `scope` + // and widen the unique key so two agents can each hold a private row under the + // same key. Existing rows backfill to space-scoped (shared). + run(migrationMarkerKey(163), () => runMigration163(db)); } function migrationMarkerKey(version: number): string { @@ -10950,3 +10955,122 @@ export function runMigration162(db: BunDatabase): void { ).run(); } } + +/** + * Migration 163 — Per-agent memory namespacing. + * + * Adds `owner_agent_id` (the agent whose private namespace a row belongs to; + * '' = space-scoped/shared) and `scope` ('agent' | 'space') to + * `space_agent_memory`, and widens the unique key from (space_id, key) to + * (space_id, owner_agent_id, key) so two agents can each hold a private row + * under the same key without colliding. + * + * SQLite cannot change a UNIQUE constraint in place, so the table is rebuilt: + * the FTS sync triggers/virtual table and B-tree indexes are dropped, the base + * table is renamed, the new shape is created, and every existing row is copied + * back as scope='space', owner_agent_id='' (shared) — no keys are lost. The FTS + * index is then rebuilt from the copied rows. + * + * `owner_agent_id` is an opaque, application-resolved key (the writing + * session's `promptProvenance.agentId`). It is intentionally NOT a foreign key: + * provenance can reference either `space_agents` or `space_long_horizon_agents`, + * and SQLite cannot express a FK to "one of two tables." Orphaned references + * (an agent hard-deleted) simply make those rows invisible to owner-scoped + * searches — they are never lost and can be reclaimed later. + * + * Idempotent: no-ops once `owner_agent_id` exists. + */ +export function runMigration163(db: BunDatabase): void { + if (!tableExists(db, 'space_agent_memory')) return; + if (tableHasColumn(db, 'space_agent_memory', 'owner_agent_id')) return; + + // Drop the FTS sync surface first — the triggers reference the base table and + // the virtual table is keyed off its rowids, both of which change on rebuild. + db.exec(`DROP TRIGGER IF EXISTS space_agent_memory_ai`); + db.exec(`DROP TRIGGER IF EXISTS space_agent_memory_ad`); + db.exec(`DROP TRIGGER IF EXISTS space_agent_memory_au`); + db.exec(`DROP TABLE IF EXISTS space_agent_memory_fts`); + + // `space_agent_core_memory` and `memory_vectors` already hold FK references + // to space_agent_memory(id). With the default legacy_alter_table=OFF, a plain + // RENAME rewrites those references to the _old table, which we then drop — + // leaving dangling references that break every later FK check. Switching to + // legacy_alter_table=ON keeps the references pointed at the table NAME, which + // we recreate below. foreign_keys is suspended for the rebuild because rows + // are copied between two tables that briefly coexist. + db.exec(`PRAGMA foreign_keys = OFF`); + db.exec(`PRAGMA legacy_alter_table = ON`); + try { + db.exec(`ALTER TABLE space_agent_memory RENAME TO space_agent_memory_old`); + db.exec(` + CREATE TABLE space_agent_memory ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL, + space_id TEXT NOT NULL, + owner_agent_id TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'space' + CHECK(scope IN ('agent', 'space')), + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + created_by_session TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + access_count INTEGER NOT NULL DEFAULT 0, + last_accessed_at INTEGER, + embedding_status TEXT NOT NULL DEFAULT 'pending' + CHECK(embedding_status IN ('pending', 'ready', 'failed')), + embedding_model TEXT, + embedding_updated_at INTEGER, + embedding_error TEXT, + embedding_revision INTEGER NOT NULL DEFAULT 0, + embedding_token TEXT NOT NULL DEFAULT '', + UNIQUE(space_id, owner_agent_id, key), + CHECK( + (scope = 'agent' AND owner_agent_id != '') + OR (scope = 'space' AND owner_agent_id = '') + ), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + ) + `); + // Backfill: every existing row becomes space-scoped (shared, unowned). No + // key is dropped — the (space_id, key) identity is preserved verbatim. + db.exec(` + INSERT INTO space_agent_memory ( + id, key, space_id, owner_agent_id, scope, content, tags, created_by_session, + created_at, updated_at, access_count, last_accessed_at, + embedding_status, embedding_model, embedding_updated_at, embedding_error, + embedding_revision, embedding_token + ) + SELECT + id, key, space_id, '', 'space', content, tags, created_by_session, + created_at, updated_at, access_count, last_accessed_at, + embedding_status, embedding_model, embedding_updated_at, embedding_error, + embedding_revision, embedding_token + FROM space_agent_memory_old + `); + db.exec(`DROP TABLE space_agent_memory_old`); + } finally { + db.exec(`PRAGMA legacy_alter_table = OFF`); + db.exec(`PRAGMA foreign_keys = ON`); + } + + // Recreate the FTS virtual table + sync triggers + base B-tree indexes. + // `createAgentMemoryTables` uses CREATE ... IF NOT EXISTS throughout, so the + // already-rebuilt base table is left intact and only the FTS surface + + // indexes are (re)created. This mirrors the rebuild pattern in + // `ensureAgentMemoryNamedPrimaryKey`. + createAgentMemoryTables(db); + // Explicitly repopulate the FTS index from the rebuilt base table. The FTS + // table was dropped above and recreated empty by createAgentMemoryTables, so + // issue the rebuild here so post-migration searchability is intentional + // rather than relying on implicit re-population. FTS5 'rebuild' is idempotent. + db.exec(`INSERT INTO space_agent_memory_fts(space_agent_memory_fts) VALUES ('rebuild')`); + // The embedding-status and owner indexes are created by the bootstrap index + // pass for fresh DBs; recreate them here so migrated DBs match. + db.exec( + `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_embedding_status ON space_agent_memory(space_id, embedding_status)` + ); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_owner ON space_agent_memory(space_id, owner_agent_id)` + ); +} diff --git a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts index 0c0a42eec..77e579d1f 100644 --- a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts +++ b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts @@ -1032,3 +1032,305 @@ describe('AgentMemoryRepository', () => { ); }); }); + +describe('AgentMemoryRepository per-agent namespacing', () => { + beforeEach(() => { + db = new BunDatabase(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + runMigrations(db, () => {}); + repo = new AgentMemoryRepository(db); + seedSpace('space-a'); + }); + + afterEach(() => { + db.close(); + }); + + test('agent-scoped writes are private and do not collide across agents', () => { + const a = repo.write({ + spaceId: 'space-a', + key: 'pref.color', + content: 'Agent A likes teal.', + ownerAgentId: 'agent-a', + }); + const b = repo.write({ + spaceId: 'space-a', + key: 'pref.color', + content: 'Agent B likes magenta.', + ownerAgentId: 'agent-b', + }); + + expect(a.ownerAgentId).toBe('agent-a'); + expect(a.scope).toBe('agent'); + expect(b.ownerAgentId).toBe('agent-b'); + // Two distinct rows under the same key — agent B did not overwrite agent A. + const rows = db + .prepare(`SELECT owner_agent_id, content FROM space_agent_memory ORDER BY owner_agent_id`) + .all() as Array<{ owner_agent_id: string; content: string }>; + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.content)).toContain('Agent A likes teal.'); + expect(rows.map((r) => r.content)).toContain('Agent B likes magenta.'); + }); + + test('updating an agent-scoped key only touches that agent row', () => { + repo.write({ + spaceId: 'space-a', + key: 'note', + content: 'A original.', + ownerAgentId: 'agent-a', + }); + repo.write({ + spaceId: 'space-a', + key: 'note', + content: 'B original.', + ownerAgentId: 'agent-b', + }); + + const updated = repo.write({ + spaceId: 'space-a', + key: 'note', + content: 'A revised.', + ownerAgentId: 'agent-a', + }); + + expect(updated.content).toBe('A revised.'); + expect(repo.read('space-a', 'note', { ownerAgentId: 'agent-a', scope: 'mine' })?.content).toBe( + 'A revised.' + ); + // Agent B's row is untouched. + expect(repo.read('space-a', 'note', { ownerAgentId: 'agent-b', scope: 'mine' })?.content).toBe( + 'B original.' + ); + }); + + test('explicit space scope writes to the shared pool even with an owner', () => { + const shared = repo.write({ + spaceId: 'space-a', + key: 'shared.fact', + content: 'Common knowledge.', + ownerAgentId: 'agent-a', + scope: 'space', + }); + expect(shared.scope).toBe('space'); + expect(shared.ownerAgentId).toBeNull(); + }); + + test('default write without owner is space-scoped', () => { + const memory = repo.write({ spaceId: 'space-a', key: 'legacy', content: 'Shared row.' }); + expect(memory.scope).toBe('space'); + expect(memory.ownerAgentId).toBeNull(); + }); + + test('read default prefers the caller row then falls back to shared', () => { + repo.write({ spaceId: 'space-a', key: 'k', content: 'Shared.', scope: 'space' }); + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-a' })?.content).toBe('A private.'); + // Agent B has no private row — falls back to shared. + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-b' })?.content).toBe('Shared.'); + // 'mine' never falls back to shared. + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-b', scope: 'mine' })).toBeNull(); + }); + + test('read without owner context reads only the shared pool', () => { + repo.write({ + spaceId: 'space-a', + key: 'secret', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + // No owner context (e.g. RPC/UI) cannot see agent-scoped rows. + expect(repo.read('space-a', 'secret')).toBeNull(); + }); + + test('read scope=all (trusted path) returns any row, preferring own then shared then other', () => { + repo.write({ spaceId: 'space-a', key: 'k', content: 'Shared.', scope: 'space' }); + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'B private.', + ownerAgentId: 'agent-b', + }); + + // Agent A sees its own row first. + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-a', scope: 'all' })?.content).toBe( + 'A private.' + ); + // A caller with no private row falls back to shared (not another agent's). + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-c', scope: 'all' })?.content).toBe( + 'Shared.' + ); + // With no shared row present, a trusted 'all' read can reach another owner's + // private row — this is the intended admin capability, not exposed via tools. + repo.delete('space-a', 'k', { scope: 'space' }); + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-c', scope: 'all' })?.content).toBe( + 'A private.' + ); + }); + + test('search default returns caller rows plus shared, never another agent private', async () => { + repo.write({ spaceId: 'space-a', key: 'shared.deploy', content: 'Deploy from dev branch.' }); + repo.write({ + spaceId: 'space-a', + key: 'a.deploy', + content: 'Agent A deploy steps.', + ownerAgentId: 'agent-a', + }); + repo.write({ + spaceId: 'space-a', + key: 'b.deploy', + content: 'Agent B deploy steps.', + ownerAgentId: 'agent-b', + }); + + const results = await repo.search('space-a', 'deploy', 10, { ownerAgentId: 'agent-a' }); + const keys = results.map((r) => r.memory.key).sort(); + expect(keys).toEqual(['a.deploy', 'shared.deploy']); + }); + + test('search scope filters (mine / space / all)', async () => { + repo.write({ spaceId: 'space-a', key: 'shared.x', content: 'Shared deploy note.' }); + repo.write({ + spaceId: 'space-a', + key: 'a.x', + content: 'Agent A deploy note.', + ownerAgentId: 'agent-a', + }); + + const mine = await repo.search('space-a', 'deploy', 10, { + ownerAgentId: 'agent-a', + scope: 'mine', + }); + expect(mine.map((r) => r.memory.key)).toEqual(['a.x']); + + const space = await repo.search('space-a', 'deploy', 10, { scope: 'space' }); + expect(space.map((r) => r.memory.key)).toEqual(['shared.x']); + + const all = await repo.search('space-a', 'deploy', 10, { scope: 'all' }); + expect(all.map((r) => r.memory.key).sort()).toEqual(['a.x', 'shared.x']); + }); + + test('search without owner context returns only shared rows', async () => { + repo.write({ + spaceId: 'space-a', + key: 'private.deploy', + content: 'Agent A deploy note.', + ownerAgentId: 'agent-a', + }); + repo.write({ spaceId: 'space-a', key: 'shared.deploy', content: 'Shared deploy note.' }); + + const results = await repo.search('space-a', 'deploy', 10); + expect(results.map((r) => r.memory.key)).toEqual(['shared.deploy']); + }); + + test('delete never removes another agent private row', () => { + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + repo.write({ spaceId: 'space-a', key: 'k', content: 'Shared.', scope: 'space' }); + + // Agent B (default mine + space) has no private row and should NOT touch A's. + const deleted = repo.delete('space-a', 'k', { ownerAgentId: 'agent-b' }); + expect(deleted).toBe(true); // it deleted the shared row + // Agent A's private row survives. + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-a', scope: 'mine' })?.content).toBe( + 'A private.' + ); + expect(repo.read('space-a', 'k')).toBeNull(); // shared row gone + }); + + test('delete mine only removes the caller row', () => { + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + repo.write({ spaceId: 'space-a', key: 'k', content: 'Shared.', scope: 'space' }); + + expect(repo.delete('space-a', 'k', { ownerAgentId: 'agent-a', scope: 'mine' })).toBe(true); + expect(repo.read('space-a', 'k', { ownerAgentId: 'agent-a', scope: 'mine' })).toBeNull(); + // Shared row untouched by a 'mine' delete. + expect(repo.read('space-a', 'k')?.content).toBe('Shared.'); + }); + + test('delete scope=all (trusted path) wipes every row under the key across owners', () => { + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'A private.', + ownerAgentId: 'agent-a', + }); + repo.write({ + spaceId: 'space-a', + key: 'k', + content: 'B private.', + ownerAgentId: 'agent-b', + }); + repo.write({ spaceId: 'space-a', key: 'k', content: 'Shared.', scope: 'space' }); + + expect(repo.delete('space-a', 'k', { scope: 'all' })).toBe(true); + expect(repo.read('space-a', 'k', { scope: 'all' })).toBeNull(); + }); + + test('consolidation does not merge memories across owners', () => { + repo.write({ + spaceId: 'space-a', + key: 'dup.a', + content: 'Use zod schemas for form validation.', + ownerAgentId: 'agent-a', + }); + repo.write({ + spaceId: 'space-a', + key: 'dup.b', + content: 'Use zod schemas for form validation.', + ownerAgentId: 'agent-b', + }); + repo.write({ spaceId: 'space-a', key: 'dup.shared', content: 'Use zod schemas for forms.' }); + + repo.consolidate({ spaceId: 'space-a', staleTtlMs: 0 }); + const owners = db + .prepare(`SELECT DISTINCT owner_agent_id AS o FROM space_agent_memory ORDER BY o`) + .all() as Array<{ o: string }>; + // All three namespaces survive — none were cross-merged. + expect(owners.map((r) => r.o)).toEqual(['', 'agent-a', 'agent-b']); + }); + + test('consolidation only ranks shared rows as core memories', () => { + repo.write({ spaceId: 'space-a', key: 'shared.hot', content: 'Frequently used shared fact.' }); + repo.recordAccess('space-a', 'shared.hot'); + repo.write({ + spaceId: 'space-a', + key: 'a.hot', + content: 'Frequently used private fact.', + ownerAgentId: 'agent-a', + }); + // Bump the private row directly by id so it would otherwise outrank. + const privateId = ( + db.prepare(`SELECT id FROM space_agent_memory WHERE owner_agent_id = 'agent-a'`).get() as { + id: number; + } + ).id; + for (let i = 0; i < 5; i++) repo.recordAccess('space-a', 'shared.hot'); + db.prepare(`UPDATE space_agent_memory SET access_count = 999 WHERE id = ?`).run(privateId); + + repo.consolidate({ spaceId: 'space-a', coreLimit: 10, staleTtlMs: 0 }); + const core = repo.listCoreMemories('space-a', 10); + // Private row must not leak into the shared core ranking. + expect(core.map((m) => m.key)).not.toContain('a.hot'); + }); +}); diff --git a/packages/daemon/tests/unit/4-space-storage/storage/migrations/migration-163-per-agent-memory-namespacing.test.ts b/packages/daemon/tests/unit/4-space-storage/storage/migrations/migration-163-per-agent-memory-namespacing.test.ts new file mode 100644 index 000000000..22c1486cf --- /dev/null +++ b/packages/daemon/tests/unit/4-space-storage/storage/migrations/migration-163-per-agent-memory-namespacing.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { Database as BunDatabase } from 'bun:sqlite'; +import { runMigration163 } from '../../../../../src/storage/schema/migrations'; +import { AgentMemoryRepository } from '../../../../../src/storage/repositories/agent-memory-repository'; + +/** + * Recreate the pre-163 shape of the agent-memory tables: a single shared pool + * keyed by UNIQUE(space_id, key) with no owner/scope columns, plus the FTS + * surface and the FK-referencing core-memory and vectors tables. + */ +function seedLegacySchema(db: BunDatabase): void { + db.exec(` + CREATE TABLE spaces ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE space_agent_memory ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL, + space_id TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + created_by_session TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + access_count INTEGER NOT NULL DEFAULT 0, + last_accessed_at INTEGER, + embedding_status TEXT NOT NULL DEFAULT 'pending' + CHECK(embedding_status IN ('pending', 'ready', 'failed')), + embedding_model TEXT, + embedding_updated_at INTEGER, + embedding_error TEXT, + embedding_revision INTEGER NOT NULL DEFAULT 0, + embedding_token TEXT NOT NULL DEFAULT '', + UNIQUE(space_id, key), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + ) + `); + db.exec(` + CREATE VIRTUAL TABLE space_agent_memory_fts USING fts5( + key, content, tags, + content='space_agent_memory', content_rowid='id', tokenize='trigram' + ) + `); + db.exec(` + CREATE TRIGGER space_agent_memory_ai AFTER INSERT ON space_agent_memory BEGIN + INSERT INTO space_agent_memory_fts(rowid, key, content, tags) + VALUES (new.id, new.key, new.content, new.tags); + END + `); + db.exec(` + CREATE TABLE memory_vectors ( + memory_id INTEGER PRIMARY KEY, + embedding BLOB NOT NULL, + dimensions INTEGER NOT NULL, + model TEXT NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (memory_id) REFERENCES space_agent_memory(id) ON DELETE CASCADE + ) + `); + db.exec(` + CREATE TABLE space_agent_core_memory ( + space_id TEXT NOT NULL, + memory_id INTEGER NOT NULL, + score REAL NOT NULL, + rank INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (space_id, memory_id), + FOREIGN KEY (memory_id) REFERENCES space_agent_memory(id) ON DELETE CASCADE + ) + `); +} + +describe('Migration 163: per-agent memory namespacing', () => { + let db: BunDatabase; + + beforeEach(() => { + db = new BunDatabase(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + seedLegacySchema(db); + db.prepare(`INSERT INTO spaces (id, created_at, updated_at) VALUES ('space-1', 1, 1)`).run(); + }); + + afterEach(() => { + db.close(); + }); + + function seedMemoryRow(id: number, key: string, content: string): void { + db.prepare( + `INSERT INTO space_agent_memory + (id, key, space_id, content, tags, created_by_session, created_at, updated_at, + access_count, last_accessed_at, embedding_status, embedding_revision, embedding_token) + VALUES (?, ?, 'space-1', ?, '[]', NULL, 1, 1, 0, NULL, 'pending', 1, ?)` + ).run(id, key, content, `token-${id}`); + } + + test('adds owner_agent_id and scope columns and preserves every existing key', () => { + seedMemoryRow(1, 'conventions.forms', 'Use zod schemas.'); + seedMemoryRow(2, 'release.process', 'Tag from dev.'); + + runMigration163(db); + + // Both columns exist. + const cols = db + .prepare(`SELECT name FROM pragma_table_info('space_agent_memory')`) + .all() + .map((r) => (r as { name: string }).name); + expect(cols).toContain('owner_agent_id'); + expect(cols).toContain('scope'); + + // Every key is preserved and backfilled to space-scoped (shared, unowned). + const rows = db + .prepare(`SELECT key, owner_agent_id, scope FROM space_agent_memory ORDER BY key`) + .all() as Array<{ key: string; owner_agent_id: string; scope: string }>; + expect(rows).toEqual([ + { key: 'conventions.forms', owner_agent_id: '', scope: 'space' }, + { key: 'release.process', owner_agent_id: '', scope: 'space' }, + ]); + }); + + test('widens the unique key so two agents can hold the same key', () => { + seedMemoryRow(1, 'shared', 'Original shared.'); + + runMigration163(db); + + const repo = new AgentMemoryRepository(db); + repo.write({ spaceId: 'space-1', key: 'shared', content: 'Agent A.', ownerAgentId: 'agent-a' }); + repo.write({ spaceId: 'space-1', key: 'shared', content: 'Agent B.', ownerAgentId: 'agent-b' }); + + const owners = db + .prepare( + `SELECT owner_agent_id FROM space_agent_memory WHERE key = 'shared' ORDER BY owner_agent_id` + ) + .all() as Array<{ owner_agent_id: string }>; + // Backfilled '' row + agent-a + agent-b all coexist under one key. + expect(owners.map((r) => r.owner_agent_id)).toEqual(['', 'agent-a', 'agent-b']); + }); + + test('keeps space_agent_core_memory FK valid after the rebuild', () => { + seedMemoryRow(1, 'core.fact', 'Important shared fact.'); + runMigration163(db); + + // The referencing table still resolves its FK to the rebuilt table — insert + // and delete must succeed without "no such table" errors. + db.prepare( + `INSERT INTO space_agent_core_memory (space_id, memory_id, score, rank, updated_at) + VALUES ('space-1', 1, 1.0, 1, 1)` + ).run(); + expect( + db.prepare(`SELECT memory_id FROM space_agent_core_memory`).get() as { memory_id: number } + ).toEqual({ memory_id: 1 }); + // Cascade-on-delete path: dropping the memory row removes the core row. + db.prepare(`DELETE FROM space_agent_memory WHERE id = 1`).run(); + expect( + db.prepare(`SELECT count(*) AS c FROM space_agent_core_memory`).get() as { c: number } + ).toEqual({ c: 0 }); + }); + + test('keeps memory_vectors FK valid after the rebuild', () => { + seedMemoryRow(1, 'vec.fact', 'Embedded fact.'); + runMigration163(db); + + db.prepare( + `INSERT INTO memory_vectors (memory_id, embedding, dimensions, model, updated_at) + VALUES (1, x'00', 1, 'm', 1)` + ).run(); + // FK cascade still wired to the rebuilt parent. + db.prepare(`DELETE FROM space_agent_memory WHERE id = 1`).run(); + expect(db.prepare(`SELECT count(*) AS c FROM memory_vectors`).get() as { c: number }).toEqual({ + c: 0, + }); + }); + + test('rebuilds the FTS index so legacy memories stay searchable', async () => { + seedMemoryRow(1, 'searchable.fact', 'Trigram searchable content.'); + runMigration163(db); + + const repo = new AgentMemoryRepository(db); + const results = await repo.search('space-1', 'searchable content', 5); + expect(results.map((r) => r.memory.key)).toEqual(['searchable.fact']); + }); + + test('is idempotent — running twice is a no-op', () => { + seedMemoryRow(1, 'once', 'Survives a second pass.'); + runMigration163(db); + // Second run must not error or duplicate rows. + runMigration163(db); + + const rows = db + .prepare(`SELECT key, scope, owner_agent_id FROM space_agent_memory`) + .all() as Array<{ key: string; scope: string; owner_agent_id: string }>; + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ key: 'once', scope: 'space', owner_agent_id: '' }); + }); +}); diff --git a/packages/daemon/tests/unit/5-space/agent/agent-memory-tools.test.ts b/packages/daemon/tests/unit/5-space/agent/agent-memory-tools.test.ts index 2a83a168b..e0bb3a5ba 100644 --- a/packages/daemon/tests/unit/5-space/agent/agent-memory-tools.test.ts +++ b/packages/daemon/tests/unit/5-space/agent/agent-memory-tools.test.ts @@ -3,6 +3,11 @@ import { Database as BunDatabase } from 'bun:sqlite'; import { runMigrations } from '../../../../src/storage/schema/index.ts'; import { AgentMemoryRepository } from '../../../../src/storage/repositories/agent-memory-repository.ts'; import { createAgentMemoryToolHandlers } from '../../../../src/lib/space/tools/agent-memory-tools.ts'; +import { + MemorySearchSchema, + MemoryReadSchema, + MemoryDeleteSchema, +} from '../../../../src/lib/space/tools/agent-memory-tools.ts'; let db: BunDatabase; let repo: AgentMemoryRepository; @@ -83,3 +88,114 @@ describe('agent memory MCP tool handlers', () => { expect(memory.tags).toEqual(['formatting', 'biome']); }); }); + +describe('agent memory MCP tool owner namespacing', () => { + beforeEach(() => { + db = new BunDatabase(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + runMigrations(db, () => {}); + repo = new AgentMemoryRepository(db); + seedSpace('space-a'); + }); + + afterEach(() => { + db.close(); + }); + + function handlersFor(ownerAgentId?: string) { + return createAgentMemoryToolHandlers({ + spaceId: 'space-a', + memoryRepo: repo, + mySessionId: 'session-agent', + ownerAgentId, + }); + } + + test('writes default to agent-scoped when ownerAgentId is configured', async () => { + const write = parseResult( + await handlersFor('agent-a')['memory.write']({ + key: 'pref.color', + content: 'Agent A likes teal.', + }) + ); + const memory = write.memory as { ownerAgentId: string | null; scope: string }; + expect(memory.ownerAgentId).toBe('agent-a'); + expect(memory.scope).toBe('agent'); + }); + + test('two agents with the same key each keep their own memory', async () => { + const a = handlersFor('agent-a'); + const b = handlersFor('agent-b'); + await a['memory.write']({ key: 'note', content: 'A note.' }); + await b['memory.write']({ key: 'note', content: 'B note.' }); + + const aRead = parseResult(await a['memory.read']({ key: 'note' })) as { + memory: { content: string }; + }; + const bRead = parseResult(await b['memory.read']({ key: 'note' })) as { + memory: { content: string }; + }; + expect(aRead.memory.content).toBe('A note.'); + expect(bRead.memory.content).toBe('B note.'); + }); + + test('explicit scope=space writes to the shared pool', async () => { + const write = parseResult( + await handlersFor('agent-a')['memory.write']({ + key: 'shared.fact', + content: 'Common knowledge.', + scope: 'space', + }) + ); + const memory = write.memory as { ownerAgentId: string | null; scope: string }; + expect(memory.scope).toBe('space'); + expect(memory.ownerAgentId).toBeNull(); + }); + + test('search default returns agent + shared, not other agents', async () => { + await handlersFor('agent-a')['memory.write']({ key: 'a.deploy', content: 'A deploy note.' }); + await handlersFor('agent-b')['memory.write']({ key: 'b.deploy', content: 'B deploy note.' }); + // A shared row visible to everyone. + await createAgentMemoryToolHandlers({ + spaceId: 'space-a', + memoryRepo: repo, + })['memory.write']({ key: 'shared.deploy', content: 'Shared deploy note.' }); + + const results = parseResult( + await handlersFor('agent-a')['memory.search']({ query: 'deploy', limit: 10 }) + ); + const keys = (results.results as Array<{ memory: { key: string } }>).map((r) => r.memory.key); + expect(keys.sort()).toEqual(['a.deploy', 'shared.deploy']); + }); + + test('agent tools never expose scope=all (per-agent isolation boundary)', () => { + // `all` would let one agent read/delete another's private memory, so it must + // not be a valid tool argument. The repo still supports it for trusted RPC/UI. + expect(MemorySearchSchema.safeParse({ query: 'x', scope: 'all' }).success).toBe(false); + expect(MemoryReadSchema.safeParse({ key: 'k', scope: 'all' }).success).toBe(false); + expect(MemoryDeleteSchema.safeParse({ key: 'k', scope: 'all' }).success).toBe(false); + // The allowed values still work. + expect(MemorySearchSchema.safeParse({ query: 'x', scope: 'mine' }).success).toBe(true); + expect(MemoryDeleteSchema.safeParse({ key: 'k', scope: 'space' }).success).toBe(true); + }); + + test('delete does not remove another agent private memory', async () => { + await handlersFor('agent-a')['memory.write']({ key: 'note', content: 'A private.' }); + // Agent B deletes the same key (default mine + space) — must not touch A. + const deleted = parseResult(await handlersFor('agent-b')['memory.delete']({ key: 'note' })); + expect(deleted.deleted).toBe(false); + + const aRead = parseResult(await handlersFor('agent-a')['memory.read']({ key: 'note' })) as { + memory?: { content: string }; + }; + expect(aRead.memory?.content).toBe('A private.'); + }); + + test('tool without ownerAgentId cannot see agent-scoped memory', async () => { + await handlersFor('agent-a')['memory.write']({ key: 'secret', content: 'A private.' }); + + const plain = createAgentMemoryToolHandlers({ spaceId: 'space-a', memoryRepo: repo }); + const read = parseResult(await plain['memory.read']({ key: 'secret' })) as { success: boolean }; + expect(read.success).toBe(false); + }); +}); diff --git a/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts b/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts index b8f0ffec5..fb9cd8348 100644 --- a/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts +++ b/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts @@ -51,6 +51,8 @@ const memories: AgentMemorySearchResult[] = [ memory: { key: 'conventions.forms', spaceId: 'space-1', + ownerAgentId: null, + scope: 'space', content: 'Use zod schemas for form validation.', tags: ['forms', 'validation'], createdBySession: null, diff --git a/packages/daemon/tests/unit/helpers/space-test-db.ts b/packages/daemon/tests/unit/helpers/space-test-db.ts index cef1e2169..7439e955c 100644 --- a/packages/daemon/tests/unit/helpers/space-test-db.ts +++ b/packages/daemon/tests/unit/helpers/space-test-db.ts @@ -380,6 +380,9 @@ export function createSpaceTables(db: BunDatabase): void { id INTEGER PRIMARY KEY, key TEXT NOT NULL, space_id TEXT NOT NULL, + owner_agent_id TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'space' + CHECK(scope IN ('agent', 'space')), content TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '', created_by_session TEXT, @@ -394,7 +397,11 @@ export function createSpaceTables(db: BunDatabase): void { embedding_error TEXT, embedding_revision INTEGER NOT NULL DEFAULT 0, embedding_token TEXT NOT NULL DEFAULT '', - UNIQUE(space_id, key), + UNIQUE(space_id, owner_agent_id, key), + CHECK( + (scope = 'agent' AND owner_agent_id != '') + OR (scope = 'space' AND owner_agent_id = '') + ), FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE ) `); @@ -466,6 +473,9 @@ export function createSpaceTables(db: BunDatabase): void { db.exec( `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_embedding_status ON space_agent_memory(space_id, embedding_status)` ); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_space_agent_memory_owner ON space_agent_memory(space_id, owner_agent_id)` + ); db.exec( `CREATE INDEX IF NOT EXISTS idx_space_agent_core_memory_rank ON space_agent_core_memory(space_id, rank)` );