diff --git a/README.md b/README.md index fb2f8dd..3cd7245 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This package turns raw sources and generated markdown knowledge into a versionab - [CLI](#cli) — `init` → `source-add` → `index` → `search` → `lint` - [Design](#design) — the invariants (immutable sources, cited claims, deterministic graph) - [Agent-Eval integration](#agent-eval-integration) — readiness bundles + release reports +- [Memory adapters](#memory-adapters) — generic memory contract + Neo4j Agent Memory bridge - [Research loop](#research-loop) — `runKnowledgeResearchLoop` + control-loop adapter - [Researcher profile](#researcher-profile) — sandbox `AgentProfile` for `runLoop` - [Pluggable knowledge sources](#pluggable-knowledge-sources) — live authorities → eval re-runs @@ -106,6 +107,10 @@ from `@tangle-network/agent-knowledge`. The `/viz` subpath exports graph insight helpers without UI dependencies. +The `/memory` subpath exports an optional memory adapter contract. Use it to +bridge episodic or graph-native memory systems into the same source-grounded +readiness/eval machinery without making `agent-knowledge` own the database. + ## Agent-Eval Integration To answer whether a candidate knowledge base actually improves agent task success, run an `@tangle-network/agent-eval` improvement loop (`runImprovementLoop`) over your KB variants on a real task corpus; each run is scored into a `RunRecord`. @@ -143,6 +148,38 @@ Pass `readiness.report` to `blockingKnowledgeEval()` from `@tangle-network/agent-eval`; use `readiness.questions` and `readiness.acquisitionPlans` to drive UI or connector workflows. +## Memory Adapters + +`agent-knowledge` does not store operational memory itself. It defines the +contract that lets a runtime read/write memory through any backend, then turn +memory hits into `SourceRecord` evidence for readiness, linting, and eval gates. + +```ts +import { + createNeo4jAgentMemoryAdapter, + memoryHitToSourceRecord, +} from '@tangle-network/agent-knowledge/memory' + +const memory = createNeo4jAgentMemoryAdapter({ client: neo4jMemoryClient }) + +const context = await memory.getContext('What does this user prefer?', { + scope: { userId: 'user-123', sessionId: 'session-456' }, + limit: 5, +}) + +const sourceRecords = context.hits.map((hit) => + memoryHitToSourceRecord(hit, { scope: { userId: 'user-123' } }), +) +``` + +The Neo4j adapter is runtime dependency-free: pass the real +`@neo4j-labs/agent-memory` client in products, or a fake client in tests. CI +typechecks against `@neo4j-labs/agent-memory@0.4.0` and covers the published +TypeScript SDK surface: `shortTerm.addMessage/searchMessages/getContext`, +`longTerm.addEntity/addPreference/addFact/searchEntities/searchPreferences`, +and `reasoning.getSimilarTraces`. Generic `search` / `getContext` and +snake_case bridge-style methods remain supported for non-hosted clients. + ## Research Loop Use `runKnowledgeResearchLoop()` when an agent is acting as a researcher or diff --git a/package.json b/package.json index 6eb7ee2..fd51e1b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "import": "./dist/cli.js", "default": "./dist/cli.js" }, + "./memory": { + "types": "./dist/memory/index.d.ts", + "import": "./dist/memory/index.js", + "default": "./dist/memory/index.js" + }, "./sources": { "types": "./dist/sources/index.d.ts", "import": "./dist/sources/index.js", @@ -69,6 +74,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@neo4j-labs/agent-memory": "0.4.0", "@tangle-network/sandbox": "^0.4.0", "@types/node": "^25.6.0", "tsup": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a126da2..fff26b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@biomejs/biome': specifier: ^2.4.15 version: 2.4.15 + '@neo4j-labs/agent-memory': + specifier: 0.4.0 + version: 0.4.0 '@tangle-network/sandbox': specifier: ^0.4.0 version: 0.4.3(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) @@ -284,6 +287,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@neo4j-labs/agent-memory@0.4.0': + resolution: {integrity: sha512-iKUi+STQm0DQqDUDx0cR486RTMCHlPfmmHBqjyN/gaVXSBlaTzYaLp63D5O/qP5tDc4E4iqiMfMITLUabllTaw==} + engines: {node: '>=20.0.0'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -1126,6 +1133,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@neo4j-labs/agent-memory@0.4.0': {} + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': diff --git a/src/index.ts b/src/index.ts index f7f2b97..d1832c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from './indexer' export * from './inspect' export * from './kb-store' export * from './lint' +export * from './memory/index' export * from './proposals' export * from './propose-from-finding' export * from './release' diff --git a/src/memory/adapter.ts b/src/memory/adapter.ts new file mode 100644 index 0000000..1a9017a --- /dev/null +++ b/src/memory/adapter.ts @@ -0,0 +1,32 @@ +import { memoryHitToSourceRecord } from './source-record' +import type { + AgentMemoryAdapter, + AgentMemoryContext, + AgentMemoryHit, + AgentMemorySearchOptions, +} from './types' + +export async function defaultGetMemoryContext( + adapter: Pick, + query: string, + options: AgentMemorySearchOptions = {}, +): Promise { + const hits = await adapter.search(query, options) + return { + query, + hits, + sourceRecords: hits.map((hit) => memoryHitToSourceRecord(hit, { scope: options.scope })), + text: renderMemoryContext(hits), + } +} + +export function renderMemoryContext(hits: AgentMemoryHit[]): string { + return hits + .map((hit, index) => { + const label = hit.title ?? `${hit.kind}:${hit.id}` + const score = + typeof hit.normalizedScore === 'number' ? ` score=${hit.normalizedScore.toFixed(3)}` : '' + return [`[${index + 1}] ${label}${score}`, hit.text].join('\n') + }) + .join('\n\n') +} diff --git a/src/memory/index.ts b/src/memory/index.ts new file mode 100644 index 0000000..8d7569e --- /dev/null +++ b/src/memory/index.ts @@ -0,0 +1,5 @@ +export * from './adapter' +export * from './neo4j' +export * from './schemas' +export * from './source-record' +export * from './types' diff --git a/src/memory/neo4j.ts b/src/memory/neo4j.ts new file mode 100644 index 0000000..162e3ac --- /dev/null +++ b/src/memory/neo4j.ts @@ -0,0 +1,584 @@ +import { stableId } from '../ids' +import { defaultGetMemoryContext } from './adapter' +import { memoryHitToSourceRecord, memoryWriteResultToSourceRecord } from './source-record' +import type { + AgentMemoryAdapter, + AgentMemoryHit, + AgentMemorySearchOptions, + AgentMemoryWriteInput, + AgentMemoryWriteResult, +} from './types' + +export interface Neo4jAgentMemoryAdapterOptions { + client: Record + id?: string +} + +export function createNeo4jAgentMemoryAdapter( + options: Neo4jAgentMemoryAdapterOptions, +): AgentMemoryAdapter { + const client = options.client + const id = options.id ?? 'neo4j-agent-memory' + return { + id, + async search(query, searchOptions = {}) { + const sdkHits = await searchNeo4jMemory(client, query, searchOptions, id) + if (sdkHits.length > 0) return sdkHits + + const result = await callOptional( + client, + ['search', 'memory_search'], + [query, neo4jOptions(searchOptions)], + ) + if (result !== undefined) return normalizeHits(result, searchOptions, id) + + const context = await callOptional( + client, + ['getContext', 'get_context'], + [query, neo4jOptions(searchOptions)], + ) + if (context !== undefined) return normalizeHits(context, searchOptions, id) + return [] + }, + async getContext(query, searchOptions = {}) { + const shortTerm = nested(client, ['shortTerm', 'short_term'], {}) + const sessionId = searchOptions.scope?.sessionId + if (sessionId) { + const conversationContext = await callOptional( + shortTerm, + ['getContext', 'get_context'], + [sessionId], + ) + if (conversationContext !== undefined) { + const hits = normalizeConversationContextHits(conversationContext, searchOptions, id) + const text = + textFromConversationContext(conversationContext) ?? + (typeof conversationContext === 'string' + ? conversationContext + : hits.length > 0 + ? renderHits(hits) + : '') + return { + query, + text, + hits, + sourceRecords: hits.map((hit) => + memoryHitToSourceRecord(hit, { scope: searchOptions.scope }), + ), + metadata: { adapter: id, rawContext: conversationContext }, + } + } + } + + const result = await callOptional( + client, + ['getContext', 'get_context'], + [query, neo4jOptions(searchOptions)], + ) + if (result === undefined) { + return defaultGetMemoryContext( + { + search: async () => { + const sdkHits = await searchNeo4jMemory(client, query, searchOptions, id) + if (sdkHits.length > 0) return sdkHits + const searchResult = await callOptional( + client, + ['search', 'memory_search'], + [query, neo4jOptions(searchOptions)], + ) + return normalizeHits(searchResult, searchOptions, id) + }, + }, + query, + searchOptions, + ) + } + if (typeof result === 'string') { + const hit: AgentMemoryHit = { + id: stableId('mem', `${id}:${query}:${result}`), + uri: `memory://${id}/context/${stableId('ctx', query)}`, + kind: 'fact', + text: result, + title: 'Memory context', + normalizedScore: 1, + } + return { + query, + text: result, + hits: [hit], + sourceRecords: [memoryHitToSourceRecord(hit, { scope: searchOptions.scope })], + metadata: { adapter: id }, + } + } + const hits = normalizeHits(result, searchOptions, id) + if (hits.length > 0) + return defaultGetMemoryContext({ search: async () => hits }, query, searchOptions) + return defaultGetMemoryContext({ search: async () => [] }, query, searchOptions) + }, + async write(input) { + const result = await writeNeo4jMemory(client, input, id) + return { + ...result, + sourceRecord: memoryWriteResultToSourceRecord(result, input.text, { scope: input.scope }), + } + }, + } +} + +async function writeNeo4jMemory( + client: Record, + input: AgentMemoryWriteInput, + adapterId: string, +): Promise { + const scope = input.scope ?? {} + const sessionId = scope.sessionId ?? input.metadata?.sessionId + let result: unknown + if (input.kind === 'message') { + const shortTerm = nested(client, ['shortTerm', 'short_term'], client) + result = await callOptional( + shortTerm, + ['addMessage'], + [ + sessionId, + input.role ?? 'user', + input.text, + { + metadata: input.metadata, + conversationId: sessionId, + userId: scope.userId, + }, + ], + ) + if (result === undefined) { + result = await callRequired( + shortTerm, + ['add_message'], + [ + { + session_id: sessionId, + sessionId, + role: input.role ?? 'user', + content: input.text, + user_identifier: scope.userId, + userIdentifier: scope.userId, + metadata: input.metadata, + }, + ], + ) + } + } else if (input.kind === 'entity') { + const longTerm = nested(client, ['longTerm', 'long_term'], client) + result = await callOptional( + longTerm, + ['addEntity'], + [ + input.entityName ?? input.title ?? input.text, + input.entityType ?? 'ENTITY', + neo4jEntityOptions(input), + ], + ) + if (result === undefined) { + result = await callRequired( + longTerm, + ['add_entity'], + [ + input.entityName ?? input.title ?? input.text, + input.entityType ?? 'ENTITY', + neo4jWriteOptions(input), + ], + ) + } + } else if (input.kind === 'preference') { + const longTerm = nested(client, ['longTerm', 'long_term'], client) + result = await callOptional( + longTerm, + ['addPreference'], + [input.category ?? 'general', input.text, neo4jPreferenceOptions(input)], + ) + if (result === undefined) { + result = await callRequired( + longTerm, + ['add_preference'], + [input.category ?? 'general', input.text, neo4jWriteOptions(input)], + ) + } + } else { + result = await callRequired( + nested(client, ['longTerm', 'long_term'], client), + ['addFact', 'add_fact'], + [ + input.subject ?? input.title ?? input.kind, + input.predicate ?? 'states', + input.object ?? input.text, + ], + ) + } + + const id = idFromResult(result) ?? input.id ?? stableId('mem', `${input.kind}:${input.text}`) + return { + accepted: true, + id, + uri: `memory://${adapterId}/${encodeURIComponent(id)}`, + kind: input.kind, + metadata: { rawResult: result }, + } +} + +async function searchNeo4jMemory( + client: Record, + query: string, + options: AgentMemorySearchOptions, + adapterId: string, +): Promise { + const searches: Promise[] = [] + const kinds = options.kinds + const includeKind = (kind: AgentMemoryHit['kind']) => !kinds?.length || kinds.includes(kind) + const shortTerm = nested(client, ['shortTerm', 'short_term'], {}) + const longTerm = nested(client, ['longTerm', 'long_term'], {}) + const reasoning = nested(client, ['reasoning'], {}) + + if (includeKind('message')) { + searches.push( + callOptional( + shortTerm, + ['searchMessages', 'search_messages'], + [query, searchMessagesOptions(options)], + ).then((result) => normalizeHits(result, { ...options, kinds: ['message'] }, adapterId)), + ) + } + + if (includeKind('entity')) { + searches.push( + callOptional( + longTerm, + ['searchEntities', 'search_entities'], + [query, searchEntitiesOptions(options)], + ).then((result) => normalizeHits(result, { ...options, kinds: ['entity'] }, adapterId)), + ) + } + + if (includeKind('preference')) { + searches.push( + callOptional( + longTerm, + ['searchPreferences', 'search_preferences'], + [query, searchPreferencesOptions(options)], + ).then((result) => normalizeHits(result, { ...options, kinds: ['preference'] }, adapterId)), + ) + } + + if (includeKind('reasoning-trace')) { + searches.push( + callOptional( + reasoning, + ['getSimilarTraces', 'get_similar_traces'], + [query, similarTracesOptions(options)], + ).then((result) => + normalizeHits(result, { ...options, kinds: ['reasoning-trace'] }, adapterId), + ), + ) + } + + const hits = (await Promise.all(searches)).flat() + return hits + .sort((a, b) => (b.normalizedScore ?? b.score ?? 0) - (a.normalizedScore ?? a.score ?? 0)) + .slice(0, options.limit) +} + +function neo4jOptions(options: AgentMemorySearchOptions): Record { + return { + limit: options.limit, + k: options.limit, + min_score: options.minScore, + minScore: options.minScore, + user_identifier: options.scope?.userId, + userIdentifier: options.scope?.userId, + session_id: options.scope?.sessionId, + sessionId: options.scope?.sessionId, + namespace: options.scope?.namespace, + tenant_id: options.scope?.tenantId, + tenantId: options.scope?.tenantId, + kinds: options.kinds, + metadata: options.metadata, + } +} + +function searchMessagesOptions(options: AgentMemorySearchOptions): Record { + return { + limit: options.limit, + sessionId: options.scope?.sessionId, + conversationId: options.scope?.sessionId, + threshold: options.minScore, + metadata: options.metadata, + } +} + +function searchEntitiesOptions(options: AgentMemorySearchOptions): Record { + return { + limit: options.limit, + type: options.metadata?.type, + } +} + +function searchPreferencesOptions(options: AgentMemorySearchOptions): Record { + return { + limit: options.limit, + category: options.metadata?.category, + } +} + +function similarTracesOptions(options: AgentMemorySearchOptions): Record { + return { + limit: options.limit, + sessionId: options.scope?.sessionId, + successOnly: options.metadata?.successOnly, + } +} + +function neo4jWriteOptions(input: AgentMemoryWriteInput): Record { + return { + user_identifier: input.scope?.userId, + userIdentifier: input.scope?.userId, + session_id: input.scope?.sessionId, + sessionId: input.scope?.sessionId, + namespace: input.scope?.namespace, + confidence: input.confidence, + metadata: input.metadata, + } +} + +function neo4jEntityOptions(input: AgentMemoryWriteInput): Record { + return { + description: input.metadata?.description ?? input.title, + } +} + +function neo4jPreferenceOptions(input: AgentMemoryWriteInput): Record { + return { + context: input.metadata?.context ?? input.title, + } +} + +async function callOptional( + target: Record, + names: string[], + args: unknown[], +): Promise { + for (const name of names) { + const fn = target[name] + if (typeof fn === 'function') return await fn.apply(target, args) + } + return undefined +} + +async function callRequired( + target: Record, + names: string[], + args: unknown[], +): Promise { + const result = await callOptional(target, names, args) + if (result !== undefined) return result + throw new Error(`Neo4j agent-memory client is missing method ${names.join(' or ')}`) +} + +function nested( + target: Record, + names: string[], + fallback: Record, +): Record { + for (const name of names) { + const value = target[name] + if (value && typeof value === 'object') return value as Record + } + return fallback +} + +function normalizeHits( + value: unknown, + options: AgentMemorySearchOptions, + adapterId: string, +): AgentMemoryHit[] { + const rawHits = rawHitsFrom(value) + return rawHits + .map((hit, index) => normalizeHit(hit, index, adapterId)) + .filter((hit): hit is AgentMemoryHit => hit !== null) + .filter((hit) => + options.minScore === undefined + ? true + : (hit.normalizedScore ?? hit.score ?? 0) >= options.minScore!, + ) + .filter((hit) => (options.kinds?.length ? options.kinds.includes(hit.kind) : true)) + .slice(0, options.limit) +} + +function normalizeHit(value: unknown, index: number, adapterId: string): AgentMemoryHit | null { + if (typeof value === 'string') { + return { + id: stableId('mem', value), + uri: `memory://${adapterId}/${stableId('mem', value)}`, + kind: 'fact', + text: value, + normalizedScore: index === 0 ? 1 : undefined, + } + } + const obj = record(value) + if (!obj) return null + const kind = memoryKind(stringField(obj, ['kind', 'type', 'label']), obj) + const text = textFromHitObject(obj, kind) + if (!text) return null + const id = stringField(obj, ['id', 'memoryId', 'uuid']) ?? stableId('mem', text) + return { + id, + uri: stringField(obj, ['uri']) ?? `memory://${adapterId}/${encodeURIComponent(id)}`, + kind, + text, + title: stringField(obj, ['title', 'name', 'task', 'subject']), + score: numberField(obj, ['score']), + normalizedScore: numberField(obj, ['normalizedScore', 'normalized_score']), + confidence: numberField(obj, ['confidence']), + createdAt: stringField(obj, ['createdAt', 'created_at']), + validUntil: stringField(obj, ['validUntil', 'valid_until']), + lastVerifiedAt: stringField(obj, ['lastVerifiedAt', 'last_verified_at']), + metadata: obj, + } +} + +function memoryKind( + value: string | undefined, + obj: Record = {}, +): AgentMemoryHit['kind'] { + if ( + value === 'message' || + value === 'entity' || + value === 'fact' || + value === 'preference' || + value === 'observation' || + value === 'reasoning-trace' + ) { + return value + } + if ('role' in obj && 'content' in obj) return 'message' + if ('name' in obj && ('type' in obj || 'entityType' in obj)) return 'entity' + if ('preference' in obj && 'category' in obj) return 'preference' + if ('task' in obj && 'steps' in obj) return 'reasoning-trace' + return 'fact' +} + +function rawHitsFrom(value: unknown): unknown[] { + if (Array.isArray(value)) return value + const obj = record(value) + if (!obj) return typeof value === 'string' ? [value] : [] + if (Array.isArray(obj.hits)) return obj.hits + if (Array.isArray(obj.results)) return obj.results + if (Array.isArray(obj.messages)) return obj.messages + if (Array.isArray(obj.recentMessages)) return obj.recentMessages + if (Array.isArray(obj.observations)) return obj.observations + if (Array.isArray(obj.reflections)) return obj.reflections + if (typeof obj.content === 'string' || typeof obj.text === 'string') return [obj] + return [] +} + +function textFromHitObject( + obj: Record, + kind: AgentMemoryHit['kind'], +): string | undefined { + const direct = stringField(obj, [ + 'text', + 'content', + 'body', + 'summary', + 'preference', + 'description', + ]) + if (direct) return direct + if (kind === 'entity') return stringField(obj, ['name']) + if (kind === 'fact') { + const subject = stringField(obj, ['subject']) + const predicate = stringField(obj, ['predicate']) + const object = stringField(obj, ['object', 'obj']) + if (subject && predicate && object) return `${subject} ${predicate} ${object}` + } + if (kind === 'reasoning-trace') { + const task = stringField(obj, ['task']) + const outcome = stringField(obj, ['outcome']) + return [task, outcome].filter(Boolean).join('\n') || undefined + } + return undefined +} + +function textFromConversationContext(value: unknown): string | undefined { + const obj = record(value) + if (!obj) return undefined + const parts = [ + ...rawHitsFrom(obj.reflections).map((hit) => normalizeContextPart(hit)), + ...rawHitsFrom(obj.observations).map((hit) => normalizeContextPart(hit)), + ...rawHitsFrom(obj.recentMessages).map((hit) => normalizeContextPart(hit)), + ].filter(Boolean) + return parts.length > 0 ? parts.join('\n\n') : undefined +} + +function normalizeConversationContextHits( + value: unknown, + options: AgentMemorySearchOptions, + adapterId: string, +): AgentMemoryHit[] { + const obj = record(value) + if (!obj) return normalizeHits(value, options, adapterId) + const raw = [ + ...rawHitsFrom(obj.reflections).map((hit) => tagContextHit(hit, 'observation', 'Reflection')), + ...rawHitsFrom(obj.observations).map((hit) => tagContextHit(hit, 'observation', 'Observation')), + ...rawHitsFrom(obj.recentMessages).map((hit) => tagContextHit(hit, 'message')), + ] + return raw.length > 0 + ? normalizeHits(raw, options, adapterId) + : normalizeHits(value, options, adapterId) +} + +function tagContextHit( + value: unknown, + kind: AgentMemoryHit['kind'], + title?: string, +): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { kind, title, ...(value as Record) } + : { kind, title, content: value } +} + +function normalizeContextPart(value: unknown): string | undefined { + if (typeof value === 'string') return value + const obj = record(value) + return obj + ? textFromHitObject(obj, memoryKind(stringField(obj, ['kind', 'type', 'label']), obj)) + : undefined +} + +function renderHits(hits: AgentMemoryHit[]): string { + return hits.map((hit) => hit.text).join('\n\n') +} + +function idFromResult(value: unknown): string | undefined { + const obj = record(value) + return obj ? stringField(obj, ['id', 'memoryId', 'uuid']) : undefined +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function stringField(obj: Record, names: string[]): string | undefined { + for (const name of names) { + const value = obj[name] + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} + +function numberField(obj: Record, names: string[]): number | undefined { + for (const name of names) { + const value = obj[name] + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} diff --git a/src/memory/schemas.ts b/src/memory/schemas.ts new file mode 100644 index 0000000..6f6e791 --- /dev/null +++ b/src/memory/schemas.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' + +export const AgentMemoryKindSchema = z.enum([ + 'message', + 'entity', + 'fact', + 'preference', + 'observation', + 'reasoning-trace', +]) + +export const AgentMemoryScopeSchema = z.object({ + tenantId: z.string().optional(), + userId: z.string().optional(), + sessionId: z.string().optional(), + namespace: z.string().optional(), + tags: z.record(z.string(), z.string()).optional(), +}) + +export const AgentMemoryHitSchema = z.object({ + id: z.string().min(1), + uri: z.string().min(1), + kind: AgentMemoryKindSchema, + text: z.string().min(1), + title: z.string().optional(), + score: z.number().optional(), + normalizedScore: z.number().optional(), + confidence: z.number().optional(), + createdAt: z.string().optional(), + validUntil: z.string().optional(), + lastVerifiedAt: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const AgentMemoryWriteInputSchema = z.object({ + kind: AgentMemoryKindSchema, + text: z.string().min(1), + id: z.string().optional(), + title: z.string().optional(), + role: z.enum(['system', 'user', 'assistant', 'tool']).optional(), + entityName: z.string().optional(), + entityType: z.string().optional(), + category: z.string().optional(), + predicate: z.string().optional(), + subject: z.string().optional(), + object: z.string().optional(), + confidence: z.number().min(0).max(1).optional(), + scope: AgentMemoryScopeSchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) diff --git a/src/memory/source-record.ts b/src/memory/source-record.ts new file mode 100644 index 0000000..2dec700 --- /dev/null +++ b/src/memory/source-record.ts @@ -0,0 +1,48 @@ +import { sha256, stableId } from '../ids' +import type { SourceRecord } from '../types' +import type { AgentMemoryHit, AgentMemoryScope, AgentMemoryWriteResult } from './types' + +export function memoryHitToSourceRecord( + hit: AgentMemoryHit, + options: { now?: () => Date; scope?: AgentMemoryScope } = {}, +): SourceRecord { + const contentHash = sha256(`${hit.uri}\n${hit.text}`) + return { + id: stableId('src', `${hit.uri}:${contentHash}`), + uri: hit.uri, + title: hit.title ?? `${hit.kind}:${hit.id}`, + mediaType: 'text/plain', + contentHash, + text: hit.text, + validUntil: hit.validUntil, + lastVerifiedAt: hit.lastVerifiedAt, + createdAt: (options.now ?? (() => new Date()))().toISOString(), + metadata: { + ...(hit.metadata ?? {}), + source: 'agent-memory', + memoryId: hit.id, + memoryKind: hit.kind, + score: hit.score, + normalizedScore: hit.normalizedScore, + confidence: hit.confidence, + scope: options.scope, + }, + } +} + +export function memoryWriteResultToSourceRecord( + result: AgentMemoryWriteResult, + text: string, + options: { now?: () => Date; scope?: AgentMemoryScope } = {}, +): SourceRecord { + return memoryHitToSourceRecord( + { + id: result.id, + uri: result.uri, + kind: result.kind, + text, + metadata: result.metadata, + }, + options, + ) +} diff --git a/src/memory/types.ts b/src/memory/types.ts new file mode 100644 index 0000000..138dda1 --- /dev/null +++ b/src/memory/types.ts @@ -0,0 +1,82 @@ +import type { SourceRecord } from '../types' + +export type AgentMemoryKind = + | 'message' + | 'entity' + | 'fact' + | 'preference' + | 'observation' + | 'reasoning-trace' + +export interface AgentMemoryScope { + tenantId?: string + userId?: string + sessionId?: string + namespace?: string + tags?: Record +} + +export interface AgentMemoryHit { + id: string + uri: string + kind: AgentMemoryKind + text: string + title?: string + score?: number + normalizedScore?: number + confidence?: number + createdAt?: string + validUntil?: string + lastVerifiedAt?: string + metadata?: Record +} + +export interface AgentMemoryContext { + query: string + text: string + hits: AgentMemoryHit[] + sourceRecords: SourceRecord[] + metadata?: Record +} + +export interface AgentMemorySearchOptions { + scope?: AgentMemoryScope + limit?: number + minScore?: number + kinds?: AgentMemoryKind[] + metadata?: Record +} + +export interface AgentMemoryWriteInput { + kind: AgentMemoryKind + text: string + id?: string + title?: string + role?: 'system' | 'user' | 'assistant' | 'tool' + entityName?: string + entityType?: string + category?: string + predicate?: string + subject?: string + object?: string + confidence?: number + scope?: AgentMemoryScope + metadata?: Record +} + +export interface AgentMemoryWriteResult { + accepted: boolean + id: string + uri: string + kind: AgentMemoryKind + sourceRecord?: SourceRecord + metadata?: Record +} + +export interface AgentMemoryAdapter { + readonly id: string + search(query: string, options?: AgentMemorySearchOptions): Promise + getContext(query: string, options?: AgentMemorySearchOptions): Promise + write(input: AgentMemoryWriteInput): Promise + flush?(): Promise +} diff --git a/tests/memory-adapter.test.ts b/tests/memory-adapter.test.ts new file mode 100644 index 0000000..5df869a --- /dev/null +++ b/tests/memory-adapter.test.ts @@ -0,0 +1,363 @@ +import type { MemoryClient } from '@neo4j-labs/agent-memory' +import { describe, expect, it } from 'vitest' +import { + AgentMemoryHitSchema, + createNeo4jAgentMemoryAdapter, + memoryHitToSourceRecord, +} from '../src/memory/index' + +describe('memory adapters', () => { + it('is type-compatible with the published Neo4j Agent Memory TypeScript SDK', () => { + type ShortTerm = MemoryClient['shortTerm'] + type LongTerm = MemoryClient['longTerm'] + type Reasoning = MemoryClient['reasoning'] + + const addMessageArgs = [ + 'session-1', + 'user', + 'hello', + { metadata: { source: 'agent-knowledge' }, conversationId: 'session-1' }, + ] satisfies Parameters + const addEntityArgs = [ + 'Alice Johnson', + 'PERSON', + { description: 'Software engineer' }, + ] satisfies Parameters + const addPreferenceArgs = [ + 'writing', + 'Prefers direct answers', + { context: 'profile' }, + ] satisfies Parameters + const addFactArgs = ['Alice Johnson', 'ROLE', 'Software engineer'] satisfies Parameters< + LongTerm['addFact'] + > + const searchMessagesArgs = [ + 'Alice', + { limit: 5, sessionId: 'session-1', threshold: 0 }, + ] satisfies Parameters + const similarTraceArgs = [ + 'debug a failed build', + { limit: 5, successOnly: true }, + ] satisfies Parameters + + expect(addMessageArgs[1]).toBe('user') + expect(addEntityArgs[1]).toBe('PERSON') + expect(addPreferenceArgs[0]).toBe('writing') + expect(addFactArgs[2]).toBe('Software engineer') + expect(searchMessagesArgs[1].limit).toBe(5) + expect(similarTraceArgs[1].successOnly).toBe(true) + }) + + it('wraps a Neo4j Agent Memory-like client for search and context', async () => { + const client = { + async search(query: string, options: Record) { + return [ + { + id: 'pref-1', + type: 'preference', + text: `${query}: likes concise answers`, + score: 0.8, + normalizedScore: 1, + userIdentifier: options.userIdentifier, + }, + ] + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const hits = await adapter.search('writing style', { + scope: { userId: 'user-1' }, + limit: 3, + }) + const context = await adapter.getContext('writing style', { + scope: { userId: 'user-1' }, + limit: 3, + }) + + expect(hits).toHaveLength(1) + expect(hits[0]).toMatchObject({ + id: 'pref-1', + kind: 'preference', + text: 'writing style: likes concise answers', + normalizedScore: 1, + }) + expect(context.text).toContain('likes concise answers') + expect(context.sourceRecords[0]?.uri).toBe('memory://neo4j-agent-memory/pref-1') + }) + + it('searches the published TypeScript SDK subclients by memory kind', async () => { + const calls: string[] = [] + const client = { + shortTerm: { + async searchMessages(query: string, options: Record) { + calls.push(`messages:${query}:${options.sessionId}`) + return [{ id: 'msg-1', role: 'user', content: 'Prefers concise updates', score: 0.8 }] + }, + }, + longTerm: { + async searchEntities(query: string, options: Record) { + calls.push(`entities:${query}:${options.type}`) + return [{ id: 'ent-1', name: 'Acme Corp', type: 'ORGANIZATION', confidence: 0.9 }] + }, + async searchPreferences(query: string, options: Record) { + calls.push(`preferences:${query}:${options.category}`) + return [{ id: 'pref-1', category: 'writing', preference: 'Use direct answers' }] + }, + }, + reasoning: { + async getSimilarTraces(task: string, options: Record) { + calls.push(`traces:${task}:${options.successOnly}`) + return [ + { id: 'trace-1', sessionId: 's-1', task: 'Debug build', steps: [], success: true }, + ] + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const hits = await adapter.search('project preferences', { + scope: { sessionId: 'session-1' }, + limit: 10, + metadata: { type: 'ORGANIZATION', category: 'writing', successOnly: true }, + }) + + expect(calls).toEqual([ + 'messages:project preferences:session-1', + 'entities:project preferences:ORGANIZATION', + 'preferences:project preferences:writing', + 'traces:project preferences:true', + ]) + expect(hits.map((hit) => hit.kind).sort()).toEqual([ + 'entity', + 'message', + 'preference', + 'reasoning-trace', + ]) + expect(hits.find((hit) => hit.kind === 'entity')?.text).toBe('Acme Corp') + expect(hits.find((hit) => hit.kind === 'reasoning-trace')?.text).toContain('Debug build') + }) + + it('uses hosted conversation context from the TypeScript SDK when scoped to a session', async () => { + const client = { + shortTerm: { + async getContext(conversationId: string) { + return { + reflections: [{ id: 'ref-1', content: `Reflection for ${conversationId}` }], + observations: [{ id: 'obs-1', content: 'User prefers terse updates.' }], + recentMessages: [{ id: 'msg-1', role: 'user', content: 'Keep it brief.' }], + } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const context = await adapter.getContext('style', { scope: { sessionId: 'conversation-1' } }) + + expect(context.text).toContain('Reflection for conversation-1') + expect(context.text).toContain('User prefers terse updates.') + expect(context.hits.map((hit) => hit.kind)).toEqual(['observation', 'observation', 'message']) + expect(context.sourceRecords).toHaveLength(3) + expect(context.sourceRecords[0]?.metadata?.source).toBe('agent-memory') + }) + + it('delegates message writes using the published TypeScript SDK positional signature', async () => { + const calls: unknown[][] = [] + const client = { + shortTerm: { + async addMessage(...args: unknown[]) { + calls.push(args) + return { id: 'msg-1' } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const result = await adapter.write({ + kind: 'message', + role: 'assistant', + text: 'I will keep updates short.', + scope: { sessionId: 'session-1', userId: 'user-1' }, + metadata: { source: 'test' }, + }) + + expect(calls[0]?.[0]).toBe('session-1') + expect(calls[0]?.[1]).toBe('assistant') + expect(calls[0]?.[2]).toBe('I will keep updates short.') + expect(calls[0]?.[3]).toMatchObject({ + conversationId: 'session-1', + userId: 'user-1', + metadata: { source: 'test' }, + }) + expect(result.uri).toBe('memory://neo4j-agent-memory/msg-1') + }) + + it('falls back to bridge-style snake_case message writes', async () => { + const calls: unknown[][] = [] + const client = { + short_term: { + async add_message(...args: unknown[]) { + calls.push(args) + return { id: 'msg-bridge' } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const result = await adapter.write({ + kind: 'message', + role: 'user', + text: 'Remember this bridge message.', + scope: { sessionId: 'session-1', userId: 'user-1' }, + }) + + expect(calls[0]?.[0]).toMatchObject({ + session_id: 'session-1', + role: 'user', + content: 'Remember this bridge message.', + user_identifier: 'user-1', + }) + expect(result.id).toBe('msg-bridge') + }) + + it('uses narrow hosted SDK options for entity and preference writes', async () => { + const calls: unknown[][] = [] + const client = { + longTerm: { + async addEntity(...args: unknown[]) { + calls.push(['entity', ...args]) + return { id: 'ent-1' } + }, + async addPreference(...args: unknown[]) { + calls.push(['preference', ...args]) + return { id: 'pref-1' } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + await adapter.write({ + kind: 'entity', + entityName: 'Alice Johnson', + entityType: 'PERSON', + text: 'Alice Johnson is a software engineer.', + metadata: { description: 'Software engineer at Acme Corp' }, + scope: { userId: 'user-1' }, + }) + await adapter.write({ + kind: 'preference', + category: 'writing', + text: 'Prefers direct answers', + metadata: { context: 'profile' }, + scope: { userId: 'user-1' }, + }) + + expect(calls[0]).toEqual([ + 'entity', + 'Alice Johnson', + 'PERSON', + { description: 'Software engineer at Acme Corp' }, + ]) + expect(calls[1]).toEqual([ + 'preference', + 'writing', + 'Prefers direct answers', + { context: 'profile' }, + ]) + }) + + it('delegates preference writes to long-term memory methods', async () => { + const calls: unknown[][] = [] + const client = { + longTerm: { + async addPreference(...args: unknown[]) { + calls.push(args) + return { id: 'pref-1' } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + const result = await adapter.write({ + kind: 'preference', + category: 'writing', + text: 'prefers direct answers', + scope: { userId: 'user-1' }, + }) + + expect(calls[0]?.[0]).toBe('writing') + expect(calls[0]?.[1]).toBe('prefers direct answers') + expect(result).toMatchObject({ + accepted: true, + id: 'pref-1', + uri: 'memory://neo4j-agent-memory/pref-1', + kind: 'preference', + }) + expect(result.sourceRecord?.metadata?.memoryKind).toBe('preference') + }) + + it('preserves Neo4j SDK unsupported errors instead of masking them with fallbacks', async () => { + const calls: string[] = [] + const client = { + longTerm: { + async addPreference() { + calls.push('addPreference') + throw new Error('Operation add_preference is not supported by this transport') + }, + async add_preference() { + calls.push('add_preference') + return { id: 'should-not-write' } + }, + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client }) + + await expect( + adapter.write({ + kind: 'preference', + category: 'writing', + text: 'prefers direct answers', + }), + ).rejects.toThrow('not supported') + expect(calls).toEqual(['addPreference']) + }) + + it('uses the configured adapter id for fallback memory URIs', async () => { + const client = { + async getContext() { + return 'Use the private project namespace.' + }, + } + const adapter = createNeo4jAgentMemoryAdapter({ client, id: 'neo4j-private' }) + + const context = await adapter.getContext('project namespace') + + expect(context.hits[0]?.uri).toMatch(/^memory:\/\/neo4j-private\//) + expect(context.sourceRecords[0]?.uri).toBe(context.hits[0]?.uri) + }) + + it('converts memory hits into source-grounded evidence records', () => { + const hit = AgentMemoryHitSchema.parse({ + id: 'fact-1', + uri: 'memory://neo4j-agent-memory/fact-1', + kind: 'fact', + text: 'User prefers short status updates.', + normalizedScore: 0.9, + }) + + const source = memoryHitToSourceRecord(hit, { + now: () => new Date('2026-06-05T00:00:00.000Z'), + scope: { userId: 'user-1' }, + }) + + expect(source.id).toMatch(/^src_/) + expect(source.uri).toBe(hit.uri) + expect(source.text).toBe(hit.text) + expect(source.createdAt).toBe('2026-06-05T00:00:00.000Z') + expect(source.metadata).toMatchObject({ + source: 'agent-memory', + memoryId: 'fact-1', + memoryKind: 'fact', + normalizedScore: 0.9, + }) + }) +}) diff --git a/tsup.config.ts b/tsup.config.ts index a78bcac..1621ec2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: 'src/index.ts', 'viz/index': 'src/viz/index.ts', cli: 'src/cli.ts', + 'memory/index': 'src/memory/index.ts', 'sources/index': 'src/sources/index.ts', 'profiles/index': 'src/profiles/index.ts', },