Merge Memory V2 runtime facade
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
function normalizeUrl(value, envName = 'MEMORY_WEAVIATE_URL') {
|
||||
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 protocol: ${url.protocol}`);
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid ${envName}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeName(value, fallback, label) {
|
||||
const raw = String(value ?? fallback).trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(raw)) throw new Error(`Invalid ${label}: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeMemory(item) {
|
||||
const text = String(item?.text ?? item?.content ?? item?.memoryText ?? '').trim();
|
||||
if (!text) return null;
|
||||
return {
|
||||
id: item?.id == null ? null : String(item.id),
|
||||
label: item?.label ?? item?.type ?? 'semantic',
|
||||
text,
|
||||
score: item?.score == null ? null : Number(item.score),
|
||||
createdAt: item?.createdAt ?? item?.created_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVector(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const vector = value.map((item) => Number(item));
|
||||
return vector.length && vector.every(Number.isFinite) ? vector : null;
|
||||
}
|
||||
|
||||
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 createWeaviateHttpClient({
|
||||
url,
|
||||
apiKey = null,
|
||||
fetchImpl = globalThis.fetch,
|
||||
timeoutMs = 3000,
|
||||
} = {}) {
|
||||
const resolvedUrl = normalizeUrl(url);
|
||||
if (!resolvedUrl) throw new Error('createWeaviateHttpClient requires MEMORY_WEAVIATE_URL');
|
||||
if (typeof fetchImpl !== 'function') throw new Error('createWeaviateHttpClient requires fetch');
|
||||
|
||||
return {
|
||||
async search({ collection, vector, userId, limit = 8 } = {}) {
|
||||
const resolvedCollection = normalizeName(collection, 'MemindMemory', 'MEMORY_WEAVIATE_COLLECTION');
|
||||
const resolvedVector = normalizeVector(vector);
|
||||
if (!resolvedVector) return [];
|
||||
const resolvedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
|
||||
const whereType = `GetObjects${resolvedCollection}WhereInpObj`;
|
||||
const query = `
|
||||
query MemoryV2Search($vector: [Float!]!, $where: ${whereType}) {
|
||||
Get {
|
||||
${resolvedCollection}(nearVector: { vector: $vector }, limit: ${resolvedLimit}, where: $where) {
|
||||
content
|
||||
text
|
||||
type
|
||||
label
|
||||
created_at
|
||||
_additional {
|
||||
id
|
||||
certainty
|
||||
distance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = {
|
||||
vector: resolvedVector,
|
||||
where: userId
|
||||
? {
|
||||
path: ['user_id'],
|
||||
operator: 'Equal',
|
||||
valueText: String(userId),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const timeout = timeoutSignal(Number(timeoutMs));
|
||||
try {
|
||||
const response = await fetchImpl(`${resolvedUrl}/v1/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
...(timeout?.signal ? { signal: timeout.signal } : {}),
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Weaviate request failed: ${response?.status ?? 'unknown'}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data?.errors) && data.errors.length) {
|
||||
throw new Error(`Weaviate GraphQL error: ${data.errors[0]?.message ?? 'unknown'}`);
|
||||
}
|
||||
return Array.isArray(data?.data?.Get?.[resolvedCollection])
|
||||
? data.data.Get[resolvedCollection].map((item) => ({
|
||||
id: item?._additional?.id,
|
||||
label: item?.label ?? item?.type,
|
||||
text: item?.text ?? item?.content,
|
||||
score: item?._additional?.certainty ?? (
|
||||
item?._additional?.distance == null ? null : 1 - Number(item._additional.distance)
|
||||
),
|
||||
createdAt: item?.createdAt ?? item?.created_at,
|
||||
}))
|
||||
: [];
|
||||
} finally {
|
||||
timeout?.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWeaviateMemoryBackend({
|
||||
enabled = false,
|
||||
url = null,
|
||||
collection = 'MemindMemory',
|
||||
client = null,
|
||||
embedQuery = null,
|
||||
defaultLimit = 8,
|
||||
unavailableReason = null,
|
||||
} = {}) {
|
||||
const resolvedUrl = normalizeUrl(url);
|
||||
const resolvedCollection = normalizeName(collection, 'MemindMemory', 'MEMORY_WEAVIATE_COLLECTION');
|
||||
const configured = Boolean(enabled && resolvedUrl);
|
||||
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: 'weaviate',
|
||||
category: 'semantic',
|
||||
role: 'knowledge-graph-fusion',
|
||||
flag: 'MEMORY_WEAVIATE_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 rows = await client.search({
|
||||
url: resolvedUrl,
|
||||
collection: resolvedCollection,
|
||||
vector,
|
||||
userId: input.userId,
|
||||
limit: input.limit ?? defaultLimit,
|
||||
});
|
||||
const memories = (Array.isArray(rows) ? rows : []).map(normalizeMemory).filter(Boolean);
|
||||
return {
|
||||
memories,
|
||||
semanticMemories: memories.map((memory) => memory.text),
|
||||
behaviorSummary: null,
|
||||
activeGoals: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user