/** * March Agent SDK - Memory Client * Port of Python march_agent/memory_client.py * * HTTP client for AI Memory service. * Uses /conversation/* endpoints matching the actual ai-memory API. */ import { APIException } from './exceptions.js' import type { MemoryMessage, MemorySearchResult, UserSummary } from './types.js' /** * Options for memory search. */ export interface MemorySearchOptions { /** User ID to filter by */ userId?: string /** Conversation ID to filter by */ conversationId?: string /** Tenant ID (agent scope/namespace) to filter by */ tenantId?: string /** Maximum number of results to return. Default: 10 */ limit?: number /** Minimum similarity score (0-100). Default: 70 */ minSimilarity?: number /** Number of context messages before/after each match. Default: 0 */ contextMessages?: number } /** * Options for memory ingestion. */ export interface MemoryIngestOptions { /** Tenant ID (agent scope/namespace) */ tenantId?: string /** User ID */ userId?: string /** Conversation ID */ conversationId?: string /** Additional metadata */ metadata?: Record } /** * Response from memory ingestion. */ export interface IngestResponse { status: 'queued' | 'error' messageId: string message: string } /** * Async HTTP client for AI memory API. * Provides long-term memory storage and semantic search. */ export class MemoryClient { private readonly baseUrl: string constructor(baseUrl: string) { this.baseUrl = baseUrl.replace(/\/$/, '') } /** * Ingest a single message into memory. * The message is queued for async processing (embedding + storage). */ async ingestMessage( role: 'user' | 'assistant' | 'system', content: string, options: MemoryIngestOptions = {} ): Promise { const url = `${this.baseUrl}/conversation/ingest` const payload = { role, content, tenant_id: options.tenantId, user_id: options.userId, conversation_id: options.conversationId, metadata: options.metadata, } try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { const errorText = await response.text() throw new APIException(`Failed to ingest message: ${response.status} - ${errorText}`, response.status) } const data = await response.json() as { status: string; message_id: string; message: string } return { status: data.status as 'queued' | 'error', messageId: data.message_id, message: data.message, } } catch (error) { if (error instanceof APIException) throw error throw new APIException(`Failed to ingest message: ${error}`) } } /** * Ingest multiple messages into memory (batch). * All messages are queued for async processing. */ async ingestMessages( messages: Array<{ role: 'user' | 'assistant' | 'system' content: string options?: MemoryIngestOptions }> ): Promise { const url = `${this.baseUrl}/conversation/ingest/batch` const payload = messages.map((msg) => ({ role: msg.role, content: msg.content, tenant_id: msg.options?.tenantId, user_id: msg.options?.userId, conversation_id: msg.options?.conversationId, metadata: msg.options?.metadata, })) try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { const errorText = await response.text() throw new APIException(`Failed to ingest messages: ${response.status} - ${errorText}`, response.status) } const data = await response.json() as Array<{ status: string; message_id: string; message: string }> return data.map((item) => ({ status: item.status as 'queued' | 'error', messageId: item.message_id, message: item.message, })) } catch (error) { if (error instanceof APIException) throw error throw new APIException(`Failed to ingest messages: ${error}`) } } /** * Search long-term memory with semantic search. * * @param query - Search query text * @param options - Search options (filters, limits, etc.) * @returns Array of search results with messages and scores */ async search( query: string, options: MemorySearchOptions = {} ): Promise { const url = new URL(`${this.baseUrl}/conversation/search`) url.searchParams.set('q', query) if (options.userId) { url.searchParams.set('user_id', options.userId) } if (options.conversationId) { url.searchParams.set('conversation_id', options.conversationId) } if (options.tenantId) { url.searchParams.set('tenant_id', options.tenantId) } if (options.limit !== undefined) { url.searchParams.set('limit', String(options.limit)) } if (options.minSimilarity !== undefined) { url.searchParams.set('min_similarity', String(options.minSimilarity)) } if (options.contextMessages !== undefined) { url.searchParams.set('context_messages', String(options.contextMessages)) } try { const response = await fetch(url.toString()) if (!response.ok) { const errorText = await response.text() throw new APIException(`Failed to search memory: ${response.status} - ${errorText}`, response.status) } const data = await response.json() as { results: Array<{ message: { id: string role: string content: string tenant_id?: string user_id?: string conversation_id?: string metadata?: Record timestamp?: string sequence_number?: number } score: number context?: Array<{ id: string role: string content: string tenant_id?: string user_id?: string conversation_id?: string metadata?: Record timestamp?: string sequence_number?: number }> }> count: number } return data.results.map((item) => ({ message: { id: item.message.id, role: item.message.role as 'user' | 'assistant', content: item.message.content, tenantId: item.message.tenant_id, userId: item.message.user_id, conversationId: item.message.conversation_id, metadata: item.message.metadata, timestamp: item.message.timestamp, sequenceNumber: item.message.sequence_number, }, score: item.score, context: item.context?.map((ctx) => ({ id: ctx.id, role: ctx.role as 'user' | 'assistant', content: ctx.content, tenantId: ctx.tenant_id, userId: ctx.user_id, conversationId: ctx.conversation_id, metadata: ctx.metadata, timestamp: ctx.timestamp, sequenceNumber: ctx.sequence_number, })), })) } catch (error) { if (error instanceof APIException) throw error throw new APIException(`Failed to search memory: ${error}`) } } /** * Get user summary. * * @param userId - User ID to get summary for * @returns User summary or null if not found */ async getUserSummary(userId: string): Promise { const url = `${this.baseUrl}/conversation/user/${userId}/summary` try { const response = await fetch(url) if (response.status === 404) { return null } if (!response.ok) { const errorText = await response.text() throw new APIException(`Failed to get user summary: ${response.status} - ${errorText}`, response.status) } const data = await response.json() as { user_id: string has_summary: boolean summary?: { text: string last_updated: string message_count: number version?: number } pending_messages?: Array<{ role: string; content: string }> } if (!data.has_summary || !data.summary) { return null } return { userId: data.user_id, text: data.summary.text, lastUpdated: data.summary.last_updated, messageCount: data.summary.message_count, version: data.summary.version ?? 1, } } catch (error) { if (error instanceof APIException) throw error throw new APIException(`Failed to get user summary: ${error}`) } } /** * Delete memory matching the provided filters. * At least one filter is required. * * @param options - Filter options (tenantId, userId, conversationId) * @returns Deletion result */ async deleteMemory(options: { tenantId?: string userId?: string conversationId?: string }): Promise<{ shortTermDeleted: number; longTermStatus: string }> { if (!options.tenantId && !options.userId && !options.conversationId) { throw new APIException('At least one filter (tenantId, userId, or conversationId) is required') } const url = new URL(`${this.baseUrl}/conversation/delete`) if (options.tenantId) { url.searchParams.set('tenant_id', options.tenantId) } if (options.userId) { url.searchParams.set('user_id', options.userId) } if (options.conversationId) { url.searchParams.set('conversation_id', options.conversationId) } try { const response = await fetch(url.toString(), { method: 'DELETE' }) if (!response.ok) { const errorText = await response.text() throw new APIException(`Failed to delete memory: ${response.status} - ${errorText}`, response.status) } const data = await response.json() as { status: string short_term_deleted: number long_term_status: string } return { shortTermDeleted: data.short_term_deleted, longTermStatus: data.long_term_status, } } catch (error) { if (error instanceof APIException) throw error throw new APIException(`Failed to delete memory: ${error}`) } } /** * @deprecated Use deleteMemory() instead * Clear user's memory (shortcut for deleteMemory with userId). */ async clearMemory(userId: string): Promise<{ shortTermDeleted: number; longTermStatus: string }> { return this.deleteMemory({ userId }) } /** * @deprecated Use ingestMessage() or ingestMessages() instead * Legacy method for backward compatibility. */ async addMessages( userId: string, conversationId: string, messages: MemoryMessage[] ): Promise<{ added: number }> { const results = await this.ingestMessages( messages.map((msg) => ({ role: msg.role, content: msg.content, options: { userId, conversationId, }, })) ) const queued = results.filter((r) => r.status === 'queued').length return { added: queued } } }