const DEFAULT_COLLECTION = 'memind_memory'; const DEFAULT_LIMIT = 8; const DEFAULT_TIMEOUT_MS = 3000; function normalizeUrl(value) { const raw = String(value ?? '').trim(); if (!raw) return null; try { const url = new URL(raw); if (!['http:', 'https:'].includes(url.protocol)) { throw new Error(`Unsupported Qdrant URL protocol: ${url.protocol}`); } return url.toString().replace(/\/$/, ''); } catch (err) { throw new Error(`Invalid MEMORY_QDRANT_URL: ${err instanceof Error ? err.message : err}`); } } function normalizeCollection(value) { const collection = String(value ?? DEFAULT_COLLECTION).trim(); if (!/^[a-zA-Z0-9_-]+$/.test(collection)) { throw new Error(`Invalid Qdrant collection name: ${collection}`); } return collection; } function normalizeVector(value) { if (!Array.isArray(value)) return null; const vector = value.map((item) => Number(item)); if (!vector.length || vector.some((item) => !Number.isFinite(item))) return null; return vector; } function normalizePayload(payload = {}) { const text = String( payload.content ?? payload.text ?? payload.memory_text ?? payload.memoryText ?? '', ).trim(); if (!text) return null; return { id: payload.id == null ? null : String(payload.id), label: payload.type ?? payload.label ?? 'semantic', text, createdAt: payload.created_at ?? payload.createdAt ?? null, }; } function normalizePoint(point = {}) { const payload = normalizePayload(point.payload ?? {}); if (!payload) return null; return { id: point.id == null ? payload.id : String(point.id), label: payload.label, text: payload.text, score: point.score == null ? null : Number(point.score), createdAt: payload.createdAt, }; } function timeoutSignal(timeoutMs) { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); return { signal: controller.signal, clear() { clearTimeout(timeout); }, }; } export function createQdrantHttpClient({ url, apiKey = null, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, } = {}) { const resolvedUrl = normalizeUrl(url); if (!resolvedUrl) throw new Error('createQdrantHttpClient requires MEMORY_QDRANT_URL'); if (typeof fetchImpl !== 'function') { throw new Error('createQdrantHttpClient requires fetch'); } async function requestJson(path, body) { const timeout = timeoutSignal(Number(timeoutMs)); try { const response = await fetchImpl(`${resolvedUrl}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(apiKey ? { 'api-key': String(apiKey) } : {}), }, body: JSON.stringify(body), ...(timeout?.signal ? { signal: timeout.signal } : {}), }); if (!response?.ok) { throw new Error(`Qdrant request failed: ${response?.status ?? 'unknown'}`); } return response.json(); } finally { timeout?.clear(); } } return { async search({ collection, vector, userId, limit = DEFAULT_LIMIT } = {}) { const resolvedCollection = normalizeCollection(collection); const resolvedVector = normalizeVector(vector); if (!resolvedVector) return []; const body = { vector: resolvedVector, limit: Math.max(1, Math.min(50, Number(limit) || DEFAULT_LIMIT)), with_payload: true, filter: userId ? { must: [ { key: 'user_id', match: { value: String(userId) }, }, ], } : undefined, }; const data = await requestJson(`/collections/${resolvedCollection}/points/search`, body); return Array.isArray(data?.result) ? data.result : []; }, }; } export function createQdrantMemoryBackend({ enabled = false, url = null, collection = DEFAULT_COLLECTION, client = null, embedQuery = null, defaultLimit = DEFAULT_LIMIT, unavailableReason = null, } = {}) { const resolvedUrl = normalizeUrl(url); const resolvedCollection = normalizeCollection(collection); const configured = Boolean(enabled && resolvedUrl && resolvedCollection); const hasClient = Boolean(client?.search); const hasEmbedding = typeof embedQuery === 'function'; const wired = Boolean(configured && hasClient && hasEmbedding); const reason = unavailableReason ?? (enabled ? ( !resolvedUrl ? 'url_not_configured' : (!hasClient ? 'client_not_configured' : 'embedding_module_not_configured') ) : 'not_configured'); async function resolveVector(input) { const explicit = normalizeVector(input?.embedding); if (explicit) return explicit; if (typeof embedQuery !== 'function' || !input?.query) return null; return normalizeVector(await embedQuery(input.query, input)); } return { name: 'qdrant', category: 'semantic', role: 'scale-out-vector-store', flag: 'MEMORY_QDRANT_ENABLED', unavailableReason: reason, url: resolvedUrl, collection: resolvedCollection, isAvailable() { return Boolean(wired); }, getUnavailableReason() { return this.isAvailable() ? null : reason; }, async resolve(input = {}) { if (!this.isAvailable()) { return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [], }; } const vector = await resolveVector(input); if (!vector) { return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [], }; } const points = await client.search({ collection: resolvedCollection, vector, userId: input.userId, limit: input.limit ?? defaultLimit, }); const memories = points.map((point) => normalizePoint(point)).filter(Boolean); return { memories, semanticMemories: memories.map((memory) => memory.text), behaviorSummary: null, activeGoals: [], }; }, }; }