Merge Memory V2 runtime facade
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
function normalizeUri(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!['bolt:', 'neo4j:', 'neo4j+s:', 'neo4j+ssc:'].includes(url.protocol)) {
|
||||
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
||||
}
|
||||
return raw;
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid MEMORY_NEO4J_URI: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(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 protocol: ${url.protocol}`);
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid MEMORY_NEO4J_HTTP_URL: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function basicAuth(username, password) {
|
||||
return Buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
export function createNeo4jHttpClient({
|
||||
httpUrl,
|
||||
username,
|
||||
password,
|
||||
database = 'neo4j',
|
||||
fetchImpl = globalThis.fetch,
|
||||
timeoutMs = 3000,
|
||||
} = {}) {
|
||||
const resolvedHttpUrl = normalizeHttpUrl(httpUrl);
|
||||
if (!resolvedHttpUrl) throw new Error('createNeo4jHttpClient requires MEMORY_NEO4J_HTTP_URL');
|
||||
if (!String(username ?? '').trim() || !String(password ?? '').trim()) {
|
||||
throw new Error('createNeo4jHttpClient requires MEMORY_NEO4J_USER and MEMORY_NEO4J_PASSWORD');
|
||||
}
|
||||
if (typeof fetchImpl !== 'function') throw new Error('createNeo4jHttpClient requires fetch');
|
||||
const resolvedDatabase = String(database ?? 'neo4j').trim() || 'neo4j';
|
||||
|
||||
async function run(statement, parameters = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Number(timeoutMs) || 3000);
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
`${resolvedHttpUrl}/db/${encodeURIComponent(resolvedDatabase)}/tx/commit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Basic ${basicAuth(username, password)}`,
|
||||
},
|
||||
body: JSON.stringify({ statements: [{ statement, parameters }] }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response?.ok) throw new Error(`Neo4j request failed: ${response?.status ?? 'unknown'}`);
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data?.errors) && data.errors.length) {
|
||||
throw new Error(`Neo4j query failed: ${data.errors[0]?.message ?? 'unknown'}`);
|
||||
}
|
||||
return data?.results?.[0]?.data ?? [];
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async resolve(input = {}) {
|
||||
const rows = await run(
|
||||
`MATCH (u:MemindUser {id: $userId})-[:HAS_MEMORY_EVENT]->(e:MemoryEvent)
|
||||
RETURN e.summary AS behaviorSummary
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $limit`,
|
||||
{ userId: String(input.userId ?? ''), limit: Math.max(1, Math.min(50, Number(input.limit) || 8)) },
|
||||
);
|
||||
const summaries = rows
|
||||
.map((row) => row?.row?.[0])
|
||||
.filter((item) => typeof item === 'string' && item.trim());
|
||||
return {
|
||||
memories: [],
|
||||
semanticMemories: [],
|
||||
behaviorSummary: summaries.join('\n') || null,
|
||||
activeGoals: [],
|
||||
};
|
||||
},
|
||||
async write(input = {}) {
|
||||
await run(
|
||||
`MERGE (u:MemindUser {id: $userId})
|
||||
CREATE (e:MemoryEvent {
|
||||
session_id: $sessionId,
|
||||
summary: $summary,
|
||||
created_at: datetime()
|
||||
})
|
||||
CREATE (u)-[:HAS_MEMORY_EVENT]->(e)
|
||||
RETURN id(e)`,
|
||||
{
|
||||
userId: String(input.userId ?? ''),
|
||||
sessionId: String(input.sessionId ?? ''),
|
||||
summary: JSON.stringify(Array.isArray(input.messages) ? input.messages : []),
|
||||
},
|
||||
);
|
||||
return { saved: 1, analyzed: 0, memories: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createNeo4jMemoryBackend({
|
||||
enabled = false,
|
||||
uri = null,
|
||||
httpUrl = null,
|
||||
username = null,
|
||||
password = null,
|
||||
client = null,
|
||||
unavailableReason = null,
|
||||
} = {}) {
|
||||
const resolvedUri = normalizeUri(uri);
|
||||
const resolvedHttpUrl = normalizeHttpUrl(httpUrl);
|
||||
const hasAuth = Boolean(String(username ?? '').trim() && String(password ?? '').trim());
|
||||
const configured = Boolean(enabled && (resolvedUri || resolvedHttpUrl) && hasAuth);
|
||||
const hasClient = Boolean(client?.resolve && client?.write);
|
||||
const wired = Boolean(configured && hasClient);
|
||||
const reason = unavailableReason
|
||||
?? (enabled
|
||||
? (!(resolvedUri || resolvedHttpUrl) ? 'uri_not_configured' : (!hasAuth ? 'auth_not_configured' : 'client_not_configured'))
|
||||
: 'not_configured');
|
||||
|
||||
return {
|
||||
name: 'neo4j',
|
||||
category: 'behavior',
|
||||
role: 'behavior-graph',
|
||||
flag: 'MEMORY_NEO4J_ENABLED',
|
||||
unavailableReason: reason,
|
||||
uri: resolvedUri,
|
||||
httpUrl: resolvedHttpUrl,
|
||||
|
||||
isAvailable() {
|
||||
return Boolean(wired);
|
||||
},
|
||||
|
||||
getUnavailableReason() {
|
||||
return this.isAvailable() ? null : reason;
|
||||
},
|
||||
|
||||
async resolve(input = {}) {
|
||||
if (!this.isAvailable()) {
|
||||
return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [] };
|
||||
}
|
||||
const payload = await client.resolve({ ...input, uri: resolvedUri, httpUrl: resolvedHttpUrl });
|
||||
return {
|
||||
memories: Array.isArray(payload?.memories) ? payload.memories : [],
|
||||
semanticMemories: Array.isArray(payload?.semanticMemories) ? payload.semanticMemories : [],
|
||||
behaviorSummary: payload?.behaviorSummary ?? null,
|
||||
activeGoals: Array.isArray(payload?.activeGoals) ? payload.activeGoals : [],
|
||||
};
|
||||
},
|
||||
|
||||
async write(input = {}) {
|
||||
if (!this.isAvailable()) return { saved: 0, analyzed: 0, memories: 0 };
|
||||
const result = await client.write({ ...input, uri: resolvedUri, httpUrl: resolvedHttpUrl });
|
||||
return {
|
||||
saved: Number(result?.saved ?? 0),
|
||||
analyzed: Number(result?.analyzed ?? 0),
|
||||
memories: Number(result?.memories ?? 0),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user