UNPKG

@lanonasis/cli

Version:

Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security

817 lines (816 loc) 37.5 kB
import axios from 'axios'; import chalk from 'chalk'; import { randomUUID } from 'crypto'; import { CLIConfig } from './config.js'; const MEMORY_TYPES = [ 'context', 'project', 'knowledge', 'reference', 'personal', 'workflow' ]; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export class APIClient { client; config; /** When true, throw on 401/403 instead of printing+exiting (for callers that handle errors) */ noExit = false; isLikelyHashedCredential(value) { return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim()); } normalizeMemoryEntry(payload) { // API responses are inconsistent across gateways: // - Some return the memory entry directly // - Some wrap it in `{ data: <memory>, message?: string }` if (payload && typeof payload === 'object') { const obj = payload; const directId = obj.id; if (typeof directId === 'string' && directId.length > 0) { return payload; } const data = obj.data; if (data && typeof data === 'object') { const dataObj = data; if (typeof dataObj.id === 'string' && dataObj.id.length > 0) { return data; } } } return payload; } tryNormalizeMemoryEntry(payload) { if (!payload || typeof payload !== 'object') { return undefined; } const normalized = this.normalizeMemoryEntry(payload); if (!normalized || typeof normalized !== 'object') { return undefined; } const normalizedRecord = normalized; return typeof normalizedRecord.id === 'string' ? normalized : undefined; } normalizeMemoryStats(payload) { if (!payload || typeof payload !== 'object') { throw new Error('Memory stats endpoint returned an invalid response.'); } const envelope = payload; const rawStats = envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) ? envelope.data : envelope; const rawByType = rawStats.memories_by_type && typeof rawStats.memories_by_type === 'object' && !Array.isArray(rawStats.memories_by_type) ? rawStats.memories_by_type : rawStats.by_type && typeof rawStats.by_type === 'object' && !Array.isArray(rawStats.by_type) ? rawStats.by_type : {}; const memoriesByType = MEMORY_TYPES.reduce((accumulator, memoryType) => { const value = rawByType[memoryType]; accumulator[memoryType] = typeof value === 'number' ? value : 0; return accumulator; }, {}); const recentMemories = Array.isArray(rawStats.recent_memories) ? rawStats.recent_memories .map((entry) => this.tryNormalizeMemoryEntry(entry)) .filter((entry) => entry !== undefined) : []; const totalMemories = typeof rawStats.total_memories === 'number' ? rawStats.total_memories : Object.values(memoriesByType).reduce((sum, count) => sum + count, 0); return { total_memories: totalMemories, memories_by_type: memoriesByType, total_size_bytes: typeof rawStats.total_size_bytes === 'number' ? rawStats.total_size_bytes : 0, avg_access_count: typeof rawStats.avg_access_count === 'number' ? rawStats.avg_access_count : 0, most_accessed_memory: this.tryNormalizeMemoryEntry(rawStats.most_accessed_memory), recent_memories: recentMemories }; } shouldUseLegacyMemoryRpcFallback(error) { const status = error?.response?.status; const errorData = error?.response?.data; const message = `${errorData?.error || ''} ${errorData?.message || ''}`.toLowerCase(); const rawRequestUrl = String(error?.config?.url || ''); const requestPath = (() => { try { if (/^https?:\/\//i.test(rawRequestUrl)) { return new URL(rawRequestUrl).pathname; } } catch { // Fall back to the raw URL if URL parsing fails. } return rawRequestUrl; })(); const normalizedRequestUrl = requestPath.startsWith('/memory') ? this.normalizeMcpPathToApi(requestPath) : requestPath; if (status === 405) { return true; } if (status === 404 && /^\/api\/v1\/memories\/[^/?#]+$/.test(normalizedRequestUrl)) { return true; } if (status === 400 && message.includes('memory id is required')) { return true; } if (status === 400 && message.includes('method not allowed')) { return true; } return false; } shouldRetryViaApiGateway(error) { const baseURL = String(error?.config?.baseURL || ''); const code = String(error?.code || ''); const alreadyRetried = Boolean(error?.config?.__retriedViaApiGateway); if (alreadyRetried) return false; if (!baseURL.includes('mcp.lanonasis.com')) return false; return code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED'; } shouldRetryViaSupabaseMemoryFunctions(error) { const status = Number(error?.response?.status || 0); if (status !== 401 && status !== 404) return false; const cfg = (error?.config || {}); if (cfg.__retriedViaSupabaseMemoryFunctions || cfg.__useSupabaseMemoryFunctions) return false; const baseURL = String(cfg.baseURL || ''); if (baseURL.includes('supabase.co')) return false; const requestUrl = String(cfg.url || ''); const normalizedRequestUrl = requestUrl.startsWith('/memory') ? this.normalizeMcpPathToApi(requestUrl) : requestUrl; if (!normalizedRequestUrl.startsWith('/api/v1/memories')) return false; const errorData = error?.response?.data; const responseText = typeof errorData === 'string' ? errorData : `${errorData?.message || ''} ${errorData?.error || ''}`; if (status === 401) { const indicatesRouteShapeDrift = /invalid jwt|missing authorization header|authentication required|token is not active or has expired/i .test(responseText); if (!indicatesRouteShapeDrift) return false; } if (status === 404) { const isGetByIdRequest = /^\/api\/v1\/memories\/[^/?#]+$/.test(normalizedRequestUrl); const indicatesMissingMcpGetRoute = /cannot get \/api\/v1\/memory\/|cannot get \/memory\/|route[_ -]?not[_ -]?found/i .test(responseText.toLowerCase()); if (!isGetByIdRequest || !indicatesMissingMcpGetRoute) return false; } const authMethod = String(this.config.get('authMethod') || ''); const token = this.config.getToken(); const hasOpaqueToken = Boolean(token) && token.split('.').length !== 3; const hasVendorKey = this.config.hasVendorKey(); return hasVendorKey || hasOpaqueToken || authMethod === 'oauth' || authMethod === 'oauth2'; } isUuid(value) { return UUID_PATTERN.test(value); } async resolveMemoryId(idOrPrefix) { const candidate = idOrPrefix.trim(); if (!candidate) { throw new Error('Memory ID is required.'); } if (this.isUuid(candidate)) { return candidate; } if (candidate.length < 8) { throw new Error('Memory ID prefix must be at least 8 characters or a full UUID.'); } const matches = new Set(); const normalizedCandidate = candidate.toLowerCase(); const limit = 100; let page = 1; while (true) { const result = await this.getMemories({ page, limit }); const memories = result.memories || result.data || []; if (memories.length === 0) { break; } for (const memory of memories) { if (memory.id.toLowerCase().startsWith(normalizedCandidate)) { matches.add(memory.id); } } const pagination = result.pagination || { total: memories.length, limit, offset: 0, has_more: false, }; const totalPages = Number.isFinite(Number(pagination.pages)) ? Number(pagination.pages) : Math.max(1, Math.ceil(Number(pagination.total || memories.length) / limit)); const hasMore = typeof pagination.has_more === 'boolean' ? pagination.has_more : page < totalPages; if (!hasMore) { break; } page += 1; } const resolvedMatches = [...matches]; if (resolvedMatches.length === 0) { throw new Error(`Memory not found for ID/prefix: ${candidate}`); } if (resolvedMatches.length > 1) { throw new Error(`Memory ID prefix is ambiguous: ${candidate}. Matches: ${resolvedMatches.slice(0, 5).join(', ')}`); } return resolvedMatches[0]; } shouldUsePostListFallback(error) { const status = Number(error?.response?.status || 0); if (status === 405) return true; if (status !== 401) return false; const message = String(error?.response?.data?.message || error?.response?.data?.error || ''); return /missing authorization header|authentication required/i.test(message); } getSupabaseFunctionsBaseUrl() { const discoveredServices = this.config.get('discoveredServices'); const candidates = [ process.env.LANONASIS_SUPABASE_URL, process.env.SUPABASE_URL, discoveredServices?.memory_base ]; for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.includes('supabase.co') && !candidate.includes('your-project.supabase.co') && !candidate.includes('<project-ref>.supabase.co')) { return candidate.replace(/\/$/, ''); } } return 'https://lanonasis.supabase.co'; } mapMemoryApiRouteToSupabaseFunctions(config, token, vendorKey) { const method = String(config.method || 'get').toLowerCase(); const rawUrl = String(config.url || ''); const url = rawUrl.startsWith('/memory') ? this.normalizeMcpPathToApi(rawUrl) : rawUrl; const mapped = config; mapped.baseURL = this.getSupabaseFunctionsBaseUrl(); mapped.headers = mapped.headers || {}; if (token) { mapped.headers.Authorization = `Bearer ${token}`; delete mapped.headers['X-API-Key']; } else if (vendorKey) { mapped.headers['X-API-Key'] = vendorKey; } // Supabase functions do not need X-Auth-Method and can reject unexpected values. delete mapped.headers['X-Auth-Method']; mapped.headers['X-Project-Scope'] = 'lanonasis-maas'; if (method === 'get' && url === '/api/v1/memories') { mapped.url = '/functions/v1/memory-list'; return mapped; } if (method === 'post' && url === '/api/v1/memories') { mapped.url = '/functions/v1/memory-create'; return mapped; } if (method === 'post' && url === '/api/v1/memories/search') { mapped.url = '/functions/v1/memory-search'; return mapped; } if (method === 'get' && url === '/api/v1/memories/stats') { mapped.url = '/functions/v1/memory-stats'; return mapped; } if (method === 'post' && url === '/api/v1/memories/bulk/delete') { mapped.url = '/functions/v1/memory-bulk-delete'; return mapped; } const idMatch = url.match(/^\/api\/v1\/memories\/([^/?#]+)$/); if (idMatch) { const id = decodeURIComponent(idMatch[1] || ''); if (method === 'get') { mapped.url = '/functions/v1/memory-get'; mapped.params = { ...(config.params || {}), id }; return mapped; } if (method === 'put' || method === 'patch') { mapped.method = 'post'; mapped.url = '/functions/v1/memory-update'; const body = (config.data && typeof config.data === 'object') ? config.data : {}; mapped.data = { id, ...body }; return mapped; } if (method === 'delete') { mapped.url = '/functions/v1/memory-delete'; mapped.params = { ...(config.params || {}), id }; return mapped; } } return mapped; } normalizeMcpPathToApi(url) { // MCP HTTP compatibility path -> API gateway REST paths if (url === '/memory') { return '/api/v1/memories'; } if (url.startsWith('/memory/')) { return url.replace('/memory/', '/api/v1/memories/'); } return url; } constructor() { this.config = new CLIConfig(); this.client = axios.create({ proxy: false // Bypass proxy to avoid redirect loops in containerized environments }); // Setup request interceptor to add auth token and headers this.client.interceptors.request.use(async (config) => { await this.config.init(); // Keep OAuth sessions alive automatically (prevents intermittent "auth required" cutouts). await this.config.refreshTokenIfNeeded(); // Service Discovery await this.config.discoverServices(); // Use appropriate base URL based on endpoint and auth method const isAuthEndpoint = config.url?.includes('/auth/') || config.url?.includes('/login') || config.url?.includes('/register') || config.url?.includes('/oauth/'); const isAuthGatewayManagementEndpoint = typeof config.url === 'string' && (config.url.startsWith('/api/v1/auth/api-keys') || config.url.startsWith('/api/v1/projects')); const discoveredServices = this.config.get('discoveredServices'); const authMethod = this.config.get('authMethod'); const vendorKey = await this.config.getVendorKeyAsync(); const token = this.config.getToken(); const useSupabaseMemoryFunctions = config.__useSupabaseMemoryFunctions === true; const normalizedMemoryUrl = typeof config.url === 'string' ? this.normalizeMcpPathToApi(config.url) : ''; const isMemoryEndpoint = normalizedMemoryUrl.startsWith('/api/v1/memories'); const forceApiFromEnv = process.env.LANONASIS_FORCE_API === 'true' || process.env.CLI_FORCE_API === 'true' || process.env.ONASIS_FORCE_API === 'true'; const forceApiFromConfig = this.config.get('forceApi') === true || this.config.get('connectionTransport') === 'api'; if (useSupabaseMemoryFunctions && isMemoryEndpoint) { const remapped = this.mapMemoryApiRouteToSupabaseFunctions(config, token || undefined, vendorKey || undefined); if (process.env.CLI_VERBOSE === 'true') { const requestId = randomUUID(); remapped.headers['X-Request-ID'] = requestId; remapped.headers['X-Transport-Mode'] = 'supabase-functions-fallback'; console.log(chalk.dim(`→ ${String(remapped.method || 'get').toUpperCase()} ${remapped.url} [${requestId}]`)); console.log(chalk.dim(` transport=supabase-functions-fallback baseURL=${remapped.baseURL}`)); } return remapped; } const forceDirectApiRetry = config .__forceDirectApiGatewayRetry === true; // NOTE: isMemoryEndpoint is intentionally NOT in forceDirectApi. // api.lanonasis.com is the vendor AI proxy, NOT the memory service. // Memory operations must go to mcp.lanonasis.com. const forceDirectApi = forceApiFromEnv || forceApiFromConfig || forceDirectApiRetry; const prefersTokenAuth = Boolean(token) && (authMethod === 'jwt' || authMethod === 'oauth' || authMethod === 'oauth2'); const useVendorKeyAuth = Boolean(vendorKey) && !prefersTokenAuth; if (authMethod === 'vendor_key' && this.isLikelyHashedCredential(vendorKey)) { throw new Error('Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.'); } // Determine the correct API base URL: // - Auth endpoints -> auth.lanonasis.com // - Memory/MCP operations (JWT or vendor key) -> mcp.lanonasis.com (the memory service) // - Other direct API calls -> api.lanonasis.com (vendor AI proxy) let apiBaseUrl; const useMcpServer = !forceDirectApi && !isAuthEndpoint && (prefersTokenAuth || useVendorKeyAuth || isMemoryEndpoint); if (isAuthEndpoint || isAuthGatewayManagementEndpoint) { apiBaseUrl = discoveredServices?.auth_base || 'https://auth.lanonasis.com'; } else if (forceDirectApi) { // Explicit force: direct to api.lanonasis.com for troubleshooting. apiBaseUrl = this.config.getApiUrl(); } else if (useMcpServer) { // Memory service lives at mcp.lanonasis.com — accepts JWT, OAuth, and vendor keys. apiBaseUrl = 'https://mcp.lanonasis.com/api/v1'; } else { apiBaseUrl = this.config.getApiUrl(); } config.baseURL = apiBaseUrl; // Path translation for MCP server: // MCP uses /memory (singular) while main API uses /memories (plural) if (useMcpServer && config.url) { config.url = config.url.replace(/\/api\/v1\/memories/g, '/memory'); } // Add project scope header for auth endpoints if (isAuthEndpoint || isAuthGatewayManagementEndpoint) { config.headers['X-Project-Scope'] = 'lanonasis-maas'; } // Enhanced Authentication Support // In forced direct-API mode, prefer bearer token auth when available. const preferVendorKeyInDirectApiMode = forceDirectApi && Boolean(vendorKey) && !prefersTokenAuth; if (preferVendorKeyInDirectApiMode) { // Vendor key authentication (validated server-side) // Send raw key - server handles hashing for comparison config.headers['X-API-Key'] = vendorKey; config.headers['X-Auth-Method'] = 'vendor_key'; } else if (prefersTokenAuth) { // JWT/OAuth token authentication takes precedence when both are present. config.headers.Authorization = `Bearer ${token}`; config.headers['X-Auth-Method'] = 'jwt'; } else if (vendorKey) { // Vendor key authentication (validated server-side) // Send raw key - server handles hashing for comparison config.headers['X-API-Key'] = vendorKey; config.headers['X-Auth-Method'] = 'vendor_key'; } else if (token) { // JWT token authentication config.headers.Authorization = `Bearer ${token}`; config.headers['X-Auth-Method'] = 'jwt'; } // Add request ID for correlation const requestId = randomUUID(); config.headers['X-Request-ID'] = requestId; // Add project scope for Golden Contract compliance config.headers['X-Project-Scope'] = 'lanonasis-maas'; if (process.env.CLI_VERBOSE === 'true') { const transportMode = forceDirectApi ? 'api-forced' : (useMcpServer ? 'mcp-http' : 'api'); config.headers['X-Transport-Mode'] = transportMode; console.log(chalk.dim(`→ ${config.method?.toUpperCase()} ${config.url} [${requestId}]`)); console.log(chalk.dim(` transport=${transportMode} baseURL=${config.baseURL}`)); } return config; }); // Setup response interceptor for error handling this.client.interceptors.response.use((response) => { if (process.env.CLI_VERBOSE === 'true') { console.log(chalk.dim(`← ${response.status} ${response.statusText}`)); } return response; }, (error) => { if (this.shouldRetryViaSupabaseMemoryFunctions(error)) { const retryConfig = { ...error.config, __retriedViaSupabaseMemoryFunctions: true, __useSupabaseMemoryFunctions: true }; return this.client.request(retryConfig); } if (this.shouldRetryViaApiGateway(error)) { const retryConfig = { ...error.config, __retriedViaApiGateway: true, __forceDirectApiGatewayRetry: true }; retryConfig.baseURL = this.config.getApiUrl(); if (typeof retryConfig.url === 'string') { retryConfig.url = this.normalizeMcpPathToApi(retryConfig.url); } return this.client.request(retryConfig); } if (error.response) { const { status, data } = error.response; if (status === 401) { // Invalidate the local auth cache so the next isAuthenticated() call // performs a fresh server check rather than returning a stale result. this.config.invalidateAuthCache().catch(() => { }); if (this.noExit) { // Caller handles the error (e.g. auth status probe) — throw so try/catch fires return Promise.reject(error); } console.error(chalk.red('✖ Authentication failed')); console.log(chalk.yellow('Please run:'), chalk.white('lanonasis auth login')); process.exit(1); } if (status === 403) { if (this.noExit) { return Promise.reject(error); } console.error(chalk.red('✖ Permission denied')); if (data.message) { console.error(chalk.gray(data.message)); } process.exit(1); } if (status === 429) { console.error(chalk.red('✖ Rate limit exceeded')); console.error(chalk.gray('Please wait a moment before trying again')); process.exit(1); } if (process.env.CLI_VERBOSE === 'true') { console.error(chalk.dim(`← ${status} ${error.response.statusText}`)); console.error(chalk.dim(JSON.stringify(data, null, 2))); } } return Promise.reject(error); }); } // Authentication - aligned with Supabase auth async login(email, password) { const response = await this.client.post('/v1/auth/login', { email, password }); return response.data; } async register(email, password, organizationName) { const response = await this.client.post('/v1/auth/signup', { email, password, name: organizationName }); return response.data; } // Memory operations - aligned with REST API canonical endpoints // All memory endpoints use /api/v1/memories path (plural, per REST conventions) async createMemory(data) { const response = await this.client.post('/api/v1/memories', data); return this.normalizeMemoryEntry(response.data); } async getMemories(params = {}) { try { const response = await this.client.get('/api/v1/memories', { params }); return response.data; } catch (error) { // Backward-compatible fallback: newer API contracts may reject GET list. if (this.shouldUsePostListFallback(error)) { const limit = Number(params.limit || 20); const page = Number(params.page || 1); const offset = Number(params.offset ?? Math.max(0, (page - 1) * limit)); // Preferred fallback: POST list endpoint (avoids triggering vector search for plain listings). const listPayload = { limit, offset }; if (params.memory_type) { listPayload.memory_type = params.memory_type; } if (params.tags) { listPayload.tags = Array.isArray(params.tags) ? params.tags : String(params.tags).split(',').map((tag) => tag.trim()).filter(Boolean); } if (params.topic_id) { listPayload.topic_id = params.topic_id; } if (params.user_id) { listPayload.user_id = params.user_id; } if (params.sort || params.sort_by) { listPayload.sort_by = params.sort_by || params.sort; } if (params.order || params.sort_order) { listPayload.sort_order = params.sort_order || params.order; } // Canonical GET listing is attempted above. For POST list fallbacks, prefer the // currently deployed singular proxy before the plural alias to avoid IDE/CLI 404s // while edge redirects catch up across environments. for (const endpoint of ['/api/v1/memory/list', '/api/v1/memories/list']) { try { const listResponse = await this.client.post(endpoint, listPayload); const payload = listResponse.data || {}; const resultsArray = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.memories) ? payload.memories : Array.isArray(payload.results) ? payload.results : []; const memories = resultsArray.map((entry) => this.normalizeMemoryEntry(entry)); const pagination = (payload.pagination && typeof payload.pagination === 'object') ? payload.pagination : {}; const total = Number.isFinite(Number(pagination.total)) ? Number(pagination.total) : Number.isFinite(Number(payload.total)) ? Number(payload.total) : memories.length; const pages = Number.isFinite(Number(pagination.total_pages)) ? Number(pagination.total_pages) : Number.isFinite(Number(pagination.pages)) ? Number(pagination.pages) : Math.max(1, Math.ceil(total / limit)); const currentPage = Number.isFinite(Number(pagination.page)) ? Number(pagination.page) : Math.max(1, Math.floor(offset / limit) + 1); const hasMore = typeof pagination.has_more === 'boolean' ? pagination.has_more : typeof pagination.has_next === 'boolean' ? pagination.has_next : (offset + memories.length) < total; return { ...payload, data: memories, memories, pagination: { total, limit, offset, has_more: hasMore, page: currentPage, pages } }; } catch (listError) { if (listError?.response?.status === 404 || listError?.response?.status === 405) { continue; } throw listError; } } // Secondary fallback: search endpoint for legacy contracts that expose only search. const searchPayload = { query: '*', limit, threshold: 0 }; if (params.memory_type) { searchPayload.memory_types = [params.memory_type]; } if (params.tags) { searchPayload.tags = Array.isArray(params.tags) ? params.tags : String(params.tags).split(',').map((tag) => tag.trim()).filter(Boolean); } if (params.topic_id) { searchPayload.topic_id = params.topic_id; } if (offset > 0) { searchPayload.offset = offset; } const fallback = await this.client.post('/api/v1/memories/search', searchPayload); const payload = fallback.data || {}; const resultsArray = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.results) ? payload.results : []; const memories = resultsArray .map((entry) => { // Some gateways/search endpoints wrap results as `{ data: <memory> }`. if (entry && typeof entry === 'object') { const obj = entry; const data = obj.data; if (data && typeof data === 'object') { const dataObj = data; if (typeof dataObj.id === 'string' && dataObj.id.length > 0) { return data; } } } return entry; }) .map((entry) => this.normalizeMemoryEntry(entry)); const total = Number.isFinite(payload.total) ? Number(payload.total) : memories.length; const pages = Math.max(1, Math.ceil(total / limit)); const currentPage = Math.max(1, Math.floor(offset / limit) + 1); return { ...payload, data: memories, memories, pagination: { total, limit, offset, has_more: (offset + memories.length) < total, page: currentPage, pages } }; } throw error; } } async getMemory(id) { const resolvedId = await this.resolveMemoryId(id); try { const response = await this.client.get(`/api/v1/memories/${encodeURIComponent(resolvedId)}`); return this.normalizeMemoryEntry(response.data); } catch (error) { if (this.shouldUseLegacyMemoryRpcFallback(error)) { const fallback = await this.client.get('/api/v1/memory/get', { params: { id: resolvedId } }); const payload = fallback.data && typeof fallback.data === 'object' ? fallback.data.data ?? fallback.data : fallback.data; return this.normalizeMemoryEntry(payload); } throw error; } } async updateMemory(id, data) { const resolvedId = await this.resolveMemoryId(id); try { const response = await this.client.put(`/api/v1/memories/${encodeURIComponent(resolvedId)}`, data); return this.normalizeMemoryEntry(response.data); } catch (error) { if (this.shouldUseLegacyMemoryRpcFallback(error) || error?.response?.status === 404) { const fallback = await this.client.post('/api/v1/memory/update', { id: resolvedId, ...data }); const payload = fallback.data && typeof fallback.data === 'object' ? fallback.data.data ?? fallback.data : fallback.data; return this.normalizeMemoryEntry(payload); } throw error; } } async deleteMemory(id) { const resolvedId = await this.resolveMemoryId(id); try { await this.client.delete(`/api/v1/memories/${encodeURIComponent(resolvedId)}`); } catch (error) { if (this.shouldUseLegacyMemoryRpcFallback(error) || error?.response?.status === 404) { await this.client.delete('/api/v1/memory/delete', { params: { id: resolvedId } }); return; } throw error; } } async searchMemories(query, options = {}) { const response = await this.client.post('/api/v1/memories/search', { query, ...options }); return response.data; } async getMemoryStats() { const response = await this.client.get('/api/v1/memories/stats'); return this.normalizeMemoryStats(response.data); } async bulkDeleteMemories(memoryIds) { const response = await this.client.post('/api/v1/memories/bulk/delete', { memory_ids: memoryIds }); return response.data; } // Topic operations - working with existing memory_topics table async createTopic(data) { const response = await this.client.post('/api/v1/topics', data); return response.data; } async getTopics() { const response = await this.client.get('/api/v1/topics'); return response.data; } async getTopic(id) { const response = await this.client.get(`/api/v1/topics/${id}`); return response.data; } async updateTopic(id, data) { const response = await this.client.put(`/api/v1/topics/${id}`, data); return response.data; } async deleteTopic(id) { await this.client.delete(`/api/v1/topics/${id}`); } // Health check async getHealth() { const response = await this.client.get('/health'); return response.data; } /** * Fetch the current user's profile from the auth gateway (GET /v1/auth/me). * Works for all auth methods: OAuth Bearer token, vendor key (X-API-Key), and JWT. * The /auth/ prefix causes the request interceptor to route this to auth_base. */ async getUserProfile() { const response = await this.client.get('/v1/auth/me'); return response.data; } // Generic HTTP methods async get(url, config) { const response = await this.client.get(url, config); return response.data; } async post(url, data, config) { const response = await this.client.post(url, data, config); return response.data; } async put(url, data, config) { const response = await this.client.put(url, data, config); return response.data; } async delete(url, config) { const response = await this.client.delete(url, config); return response.data; } // Generic request method async request(config) { const response = await this.client.request(config); return response.data; } } export const apiClient = new APIClient();