diff --git a/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md index e019cb8..0baf49e 100644 --- a/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md +++ b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md @@ -2,7 +2,7 @@ ## Memory/retrieval slice — TESS-MEM-001 -Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; callers cannot select an adapter or namespace. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry provenance and namespace metadata. Capture/redaction occurs before adapter persistence; startup context is bounded and ordered so project/flat-file truth can take precedence over retrieved material. +Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context is bounded and ordered so project/flat-file truth can take precedence over retrieved material. Registration remains replaceable-adapter based: `registerOperatorMemoryAdapter(kind, factory)` and `createOperatorMemoryPlugin(config)` resolve a configured adapter. Identity and namespace are configuration/scope data; no interaction-agent name is embedded in keys or defaults. diff --git a/packages/memory/src/operator-memory-plugin.test.ts b/packages/memory/src/operator-memory-plugin.test.ts index d9d2b55..adfd2a3 100644 --- a/packages/memory/src/operator-memory-plugin.test.ts +++ b/packages/memory/src/operator-memory-plugin.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; import { createOperatorMemoryPlugin } from './operator-memory-plugin.js'; +import type { Insight, InsightSearchResult, NewInsight } from './types.js'; function adapter() { return { name: 'test', embedder: null, - storeInsight: vi.fn(async (value) => ({ ...value, id: '1', createdAt: new Date() })), - searchInsights: vi.fn(async () => []), + storeInsight: vi.fn( + async (value: NewInsight): Promise => ({ + ...value, + id: '1', + createdAt: new Date(), + }), + ), + searchInsights: vi.fn(async (): Promise => []), getInsight: vi.fn(), deleteInsight: vi.fn(), getPreference: vi.fn(), @@ -18,24 +25,33 @@ function adapter() { } describe('OperatorMemoryPlugin', () => { - it('isolates adapter identity by server-derived tenant, owner, and namespace', async () => { + it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => { const memory = adapter(); const plugin = createOperatorMemoryPlugin({ adapter: memory, instanceId: 'Nova', - redact: (v) => v, + namespace: 'operator-memory', + redact: (value) => value, }); await plugin.capture( - { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 's', namespace: 'private' }, + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, { content: 'one', source: 'test', category: 'note' }, ); await plugin.capture( - { tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 's', namespace: 'private' }, + { tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' }, { content: 'two', source: 'test', category: 'note' }, ); - expect(memory.storeInsight.mock.calls.map((call: unknown[]) => call[0].userId)).toEqual([ - 'tenant-a:owner-a:private', - 'tenant-b:owner-a:private', + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' }, + { content: 'three', source: 'test', category: 'note' }, + ); + + expect( + memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId), + ).toEqual([ + '["operator-memory","tenant-a","owner-a","session-a"]', + '["operator-memory","tenant-b","owner-a","session-a"]', + '["operator-memory","tenant-a","owner-a","session-b"]', ]); }); @@ -47,12 +63,42 @@ describe('OperatorMemoryPlugin', () => { const plugin = createOperatorMemoryPlugin({ adapter: memory, instanceId: 'Nova', - redact: (v) => v, + namespace: 'operator-memory', + redact: (value) => value, }); - const results = await plugin.search( - { tenantId: 't', ownerId: 'o', sessionId: 's', namespace: 'n' }, - 'x', + + const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x'); + + expect(results[0]?.provenance).toEqual({ + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }); + }); + + it('redacts content before adapter persistence and records configured provenance metadata', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value.replace('secret', '[REDACTED]'), + }); + + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'secret note', source: 'project', category: 'note' }, + ); + + expect(memory.storeInsight).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED] note', + metadata: { + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }, + }), ); - expect(results[0]?.provenance.instanceId).toBe('Nova'); }); }); diff --git a/packages/memory/src/operator-memory-plugin.ts b/packages/memory/src/operator-memory-plugin.ts index 64b9821..766536f 100644 --- a/packages/memory/src/operator-memory-plugin.ts +++ b/packages/memory/src/operator-memory-plugin.ts @@ -2,16 +2,19 @@ import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js'; /** Immutable server-derived boundary; callers never choose an adapter namespace. */ export interface OperatorMemoryScope { - tenantId: string; - ownerId: string; - sessionId: string; - namespace: string; + readonly tenantId: string; + readonly ownerId: string; + readonly sessionId: string; } export interface OperatorMemoryConfig { - adapter: MemoryAdapter; - instanceId: string; - maxStartupContext?: number; + /** Adapter injection is deployment/lifecycle configuration, never caller input. */ + readonly adapter: MemoryAdapter; + /** Configured agent identity; it is metadata rather than a storage key default. */ + readonly instanceId: string; + /** Configured storage partition; callers cannot select a namespace. */ + readonly namespace: string; + readonly maxStartupContext?: number; redact(content: string): string; } @@ -34,20 +37,32 @@ export interface OperatorMemoryPlugin { startupContext(scope: OperatorMemoryScope): Promise; } -function scopedUserId(scope: OperatorMemoryScope): string { - return `${scope.tenantId}:${scope.ownerId}:${scope.namespace}`; +function scopedUserId(scope: OperatorMemoryScope, namespace: string): string { + // JSON tuple encoding avoids delimiter collisions between independently scoped IDs. + return JSON.stringify([namespace, scope.tenantId, scope.ownerId, scope.sessionId]); +} + +function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig { + const instanceId = config.instanceId.trim(); + const namespace = config.namespace.trim(); + const maxStartupContext = config.maxStartupContext ?? 8; + if (instanceId.length === 0 || namespace.length === 0) { + throw new Error('Operator memory instance ID and namespace must be configured'); + } + if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) { + throw new Error('Operator memory startup context limit must be a positive integer'); + } + return Object.freeze({ ...config, instanceId, namespace, maxStartupContext }); } /** Creates a leaf-package, replaceable memory adapter facade. */ export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin { - const mapResult = ( - scope: OperatorMemoryScope, - result: InsightSearchResult, - ): OperatorMemoryResult => ({ + const pluginConfig = normalizeConfig(config); + const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({ ...result, provenance: { - instanceId: config.instanceId, - namespace: scope.namespace, + instanceId: pluginConfig.instanceId, + namespace: pluginConfig.namespace, source: String(result.metadata?.['source'] ?? 'retrieval'), }, }); @@ -56,18 +71,28 @@ export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): Operat query: string, limit = 10, ): Promise => - (await config.adapter.searchInsights(scopedUserId(scope), query, { limit })).map((result) => - mapResult(scope, result), - ); + ( + await pluginConfig.adapter.searchInsights( + scopedUserId(scope, pluginConfig.namespace), + query, + { + limit, + }, + ) + ).map(mapResult); return { async capture(scope, input) { return config.adapter.storeInsight({ - userId: scopedUserId(scope), - content: config.redact(input.content), + userId: scopedUserId(scope, pluginConfig.namespace), + content: pluginConfig.redact(input.content), source: input.source, category: input.category, relevanceScore: 1, - metadata: { namespace: scope.namespace, instanceId: config.instanceId }, + metadata: { + namespace: pluginConfig.namespace, + instanceId: pluginConfig.instanceId, + source: input.source, + }, }); }, search, @@ -75,10 +100,13 @@ export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): Operat return search(scope, '*', limit); }, async stats(scope) { - return { namespace: scope.namespace, resultCount: (await search(scope, '*', 100)).length }; + return { + namespace: pluginConfig.namespace, + resultCount: (await search(scope, '*', 100)).length, + }; }, async startupContext(scope) { - return search(scope, '*', config.maxStartupContext ?? 8); + return search(scope, '*', pluginConfig.maxStartupContext); }, }; }