Merge Memory V2 runtime facade
This commit is contained in:
+359
@@ -0,0 +1,359 @@
|
||||
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
|
||||
|
||||
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
||||
const MAX_MEMORY_TEXT_LENGTH = 2000;
|
||||
const MAX_CONTEXT_ITEMS = 50;
|
||||
|
||||
function normalizeText(value, maxLength = MAX_MEMORY_TEXT_LENGTH) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return null;
|
||||
return text.length > maxLength ? text.slice(0, maxLength) : text;
|
||||
}
|
||||
|
||||
function readFlag(env, key, fallback) {
|
||||
const raw = env?.[key];
|
||||
if (raw == null || raw === '') return fallback;
|
||||
const normalized = String(raw).trim().toLowerCase();
|
||||
if (TRUE_VALUES.has(normalized)) return true;
|
||||
if (FALSE_VALUES.has(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function emptyResolveResult({
|
||||
enabled,
|
||||
skipped = false,
|
||||
reason = null,
|
||||
degraded = false,
|
||||
source = null,
|
||||
} = {}) {
|
||||
return {
|
||||
ok: !degraded,
|
||||
enabled: Boolean(enabled),
|
||||
skipped,
|
||||
reason,
|
||||
degraded,
|
||||
source,
|
||||
profile: null,
|
||||
semanticMemories: [],
|
||||
behaviorSummary: null,
|
||||
activeGoals: [],
|
||||
contextGoals: [],
|
||||
memories: [],
|
||||
};
|
||||
}
|
||||
|
||||
function skippedWriteResult(reason) {
|
||||
return {
|
||||
ok: true,
|
||||
enabled: false,
|
||||
skipped: true,
|
||||
reason,
|
||||
source: null,
|
||||
saved: 0,
|
||||
analyzed: 0,
|
||||
memories: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMemoryV2Policy({ env = process.env, overrides = {} } = {}) {
|
||||
const legacyDefault = readFlag(env, 'USER_CONVERSATION_MEMORY_ENABLED', true);
|
||||
const enabled = readFlag(env, 'MEMORY_ENABLED', legacyDefault);
|
||||
return {
|
||||
enabled,
|
||||
profileEnabled: readFlag(env, 'MEMORY_PROFILE_ENABLED', enabled),
|
||||
eventLogEnabled: readFlag(env, 'MEMORY_EVENT_LOG_ENABLED', enabled),
|
||||
vectorEnabled: readFlag(env, 'MEMORY_VECTOR_ENABLED', false),
|
||||
backend: String(env?.MEMORY_BACKEND ?? 'legacy').trim() || 'legacy',
|
||||
failOpen: readFlag(env, 'MEMORY_FAIL_OPEN', true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyMemoryBackend(conversationMemoryService) {
|
||||
return {
|
||||
name: 'legacy-conversation-memory',
|
||||
|
||||
isAvailable() {
|
||||
return Boolean(conversationMemoryService);
|
||||
},
|
||||
|
||||
async resolve({ userId, limit = 40 } = {}) {
|
||||
if (!conversationMemoryService?.listMemories || !userId) {
|
||||
return { memories: [] };
|
||||
}
|
||||
const memories = await conversationMemoryService.listMemories(userId, { limit });
|
||||
return { memories };
|
||||
},
|
||||
|
||||
async write({ userId, sessionId, messages = [] } = {}) {
|
||||
if (!conversationMemoryService?.saveAndAnalyze || !userId || !sessionId) {
|
||||
return { saved: 0, analyzed: 0, memories: 0 };
|
||||
}
|
||||
return conversationMemoryService.saveAndAnalyze(sessionId, userId, messages);
|
||||
},
|
||||
|
||||
async compact({ userId } = {}) {
|
||||
if (!conversationMemoryService?.analyzeUser || !userId) {
|
||||
return { analyzed: 0, memories: 0 };
|
||||
}
|
||||
return conversationMemoryService.analyzeUser(userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemoryItem(item) {
|
||||
if (typeof item === 'string') {
|
||||
const text = normalizeText(item);
|
||||
return text ? { label: 'memory', text } : null;
|
||||
}
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const text = normalizeText(
|
||||
item.text ?? item.content ?? item.memoryText ?? item.memory_text ?? '',
|
||||
);
|
||||
if (!text) return null;
|
||||
const normalized = {
|
||||
label: normalizeText(item.label ?? item.type ?? 'memory', 80) ?? 'memory',
|
||||
text,
|
||||
};
|
||||
if (item.id != null) normalized.id = String(item.id);
|
||||
const hasCreatedAt = Object.prototype.hasOwnProperty.call(item, 'createdAt')
|
||||
|| Object.prototype.hasOwnProperty.call(item, 'created_at');
|
||||
if (hasCreatedAt) normalized.createdAt = item.createdAt ?? item.created_at ?? null;
|
||||
const score = item.score ?? item.confidence;
|
||||
if (score != null && Number.isFinite(Number(score))) normalized.score = Number(score);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeStringList(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values
|
||||
.map((value) => (
|
||||
typeof value === 'string'
|
||||
? normalizeText(value)
|
||||
: normalizeText(value?.text ?? value?.content ?? value?.summary ?? '')
|
||||
))
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_CONTEXT_ITEMS);
|
||||
}
|
||||
|
||||
function normalizeMemoryList(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values
|
||||
.map((item) => normalizeMemoryItem(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_CONTEXT_ITEMS);
|
||||
}
|
||||
|
||||
function normalizeResolvePayload(payload, source, policy) {
|
||||
const memories = normalizeMemoryList(payload?.memories);
|
||||
const semanticMemories = Array.isArray(payload?.semanticMemories)
|
||||
? normalizeStringList(payload.semanticMemories)
|
||||
: memories;
|
||||
const contextGoals = normalizeStringList(
|
||||
Array.isArray(payload?.contextGoals)
|
||||
? payload.contextGoals
|
||||
: (Array.isArray(payload?.userLongTermGoals) ? payload.userLongTermGoals : payload?.activeGoals),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
skipped: false,
|
||||
reason: null,
|
||||
degraded: false,
|
||||
source,
|
||||
profile: policy.profileEnabled ? (payload?.profile ?? null) : null,
|
||||
semanticMemories,
|
||||
behaviorSummary: normalizeText(payload?.behaviorSummary, 4000),
|
||||
activeGoals: contextGoals,
|
||||
contextGoals,
|
||||
memories,
|
||||
};
|
||||
}
|
||||
|
||||
function supportsOperation(backend, operation) {
|
||||
if (!operation) return true;
|
||||
return typeof backend?.[operation] === 'function';
|
||||
}
|
||||
|
||||
function isBackendAvailable(backend) {
|
||||
try {
|
||||
return backend?.isAvailable?.() !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectBackend(backends, policy, operation = null) {
|
||||
if (!Array.isArray(backends) || backends.length === 0) return null;
|
||||
const preferred = policy.backend;
|
||||
if (preferred && preferred !== 'legacy') {
|
||||
const exact = backends.find((backend) => backend?.name === preferred);
|
||||
if (exact && isBackendAvailable(exact) && supportsOperation(exact, operation)) {
|
||||
return exact;
|
||||
}
|
||||
}
|
||||
return backends.find((backend) => (
|
||||
isBackendAvailable(backend) && supportsOperation(backend, operation)
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
function backendStatus(backend) {
|
||||
const name = String(backend?.name ?? 'unknown');
|
||||
let available = true;
|
||||
try {
|
||||
available = backend?.isAvailable?.() !== false;
|
||||
} catch {
|
||||
available = false;
|
||||
}
|
||||
const status = {
|
||||
name,
|
||||
available,
|
||||
supports: {
|
||||
resolve: typeof backend?.resolve === 'function',
|
||||
write: typeof backend?.write === 'function',
|
||||
compact: typeof backend?.compact === 'function',
|
||||
},
|
||||
};
|
||||
if (backend?.category) status.category = String(backend.category);
|
||||
if (backend?.role) status.role = String(backend.role);
|
||||
if (backend?.flag) status.flag = String(backend.flag);
|
||||
const reason = typeof backend?.getUnavailableReason === 'function'
|
||||
? backend.getUnavailableReason()
|
||||
: backend?.unavailableReason;
|
||||
if (!available && reason) status.reason = String(reason);
|
||||
return status;
|
||||
}
|
||||
|
||||
export function createMemoryV2({
|
||||
legacyMemoryService = null,
|
||||
backends = null,
|
||||
policy = null,
|
||||
env = process.env,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const resolvedPolicy = policy ?? resolveMemoryV2Policy({ env });
|
||||
const resolvedBackends = backends ?? [
|
||||
createLegacyMemoryBackend(legacyMemoryService),
|
||||
...createMemoryV2PluginBackends(),
|
||||
];
|
||||
|
||||
async function failOpen(operation, err, fallback) {
|
||||
logger?.warn?.(
|
||||
`[memory-v2] ${operation} degraded: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
if (resolvedPolicy.failOpen) return fallback;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function resolve(input = {}) {
|
||||
if (!resolvedPolicy.enabled) {
|
||||
return emptyResolveResult({ enabled: false, skipped: true, reason: 'disabled' });
|
||||
}
|
||||
const backend = selectBackend(resolvedBackends, resolvedPolicy, 'resolve');
|
||||
if (!backend?.resolve) {
|
||||
return emptyResolveResult({ enabled: true, skipped: true, reason: 'no_backend' });
|
||||
}
|
||||
try {
|
||||
const payload = await backend.resolve(input);
|
||||
return normalizeResolvePayload(payload, backend.name ?? 'unknown', resolvedPolicy);
|
||||
} catch (err) {
|
||||
return failOpen(
|
||||
'resolve',
|
||||
err,
|
||||
emptyResolveResult({
|
||||
enabled: true,
|
||||
degraded: true,
|
||||
reason: 'backend_failure',
|
||||
source: backend.name ?? 'unknown',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function write(input = {}) {
|
||||
if (!resolvedPolicy.enabled) return skippedWriteResult('disabled');
|
||||
if (!resolvedPolicy.eventLogEnabled) return skippedWriteResult('event_log_disabled');
|
||||
const backend = selectBackend(resolvedBackends, resolvedPolicy, 'write');
|
||||
if (!backend?.write) return skippedWriteResult('no_backend');
|
||||
try {
|
||||
const result = await backend.write(input);
|
||||
return {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
skipped: false,
|
||||
reason: null,
|
||||
source: backend.name ?? 'unknown',
|
||||
saved: Number(result?.saved ?? 0),
|
||||
analyzed: Number(result?.analyzed ?? 0),
|
||||
memories: Number(result?.memories ?? 0),
|
||||
};
|
||||
} catch (err) {
|
||||
return failOpen('write', err, {
|
||||
ok: false,
|
||||
enabled: true,
|
||||
skipped: true,
|
||||
reason: 'backend_failure',
|
||||
source: backend.name ?? 'unknown',
|
||||
saved: 0,
|
||||
analyzed: 0,
|
||||
memories: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function compact(input = {}) {
|
||||
if (!resolvedPolicy.enabled) {
|
||||
return { ok: true, enabled: false, skipped: true, reason: 'disabled' };
|
||||
}
|
||||
const backend = selectBackend(resolvedBackends, resolvedPolicy, 'compact');
|
||||
if (!backend?.compact) {
|
||||
return { ok: true, enabled: true, skipped: true, reason: 'no_backend' };
|
||||
}
|
||||
try {
|
||||
const result = await backend.compact(input);
|
||||
return {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
skipped: false,
|
||||
reason: null,
|
||||
source: backend.name ?? 'unknown',
|
||||
analyzed: Number(result?.analyzed ?? 0),
|
||||
memories: Number(result?.memories ?? 0),
|
||||
};
|
||||
} catch (err) {
|
||||
return failOpen('compact', err, {
|
||||
ok: false,
|
||||
enabled: true,
|
||||
skipped: true,
|
||||
reason: 'backend_failure',
|
||||
source: backend.name ?? 'unknown',
|
||||
analyzed: 0,
|
||||
memories: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
const backends = resolvedBackends.map((backend) => backendStatus(backend));
|
||||
const selected = selectBackend(resolvedBackends, resolvedPolicy, 'resolve');
|
||||
return {
|
||||
enabled: Boolean(resolvedPolicy.enabled),
|
||||
backend: resolvedPolicy.backend,
|
||||
selectedBackend: selected?.name ?? null,
|
||||
profileEnabled: Boolean(resolvedPolicy.profileEnabled),
|
||||
eventLogEnabled: Boolean(resolvedPolicy.eventLogEnabled),
|
||||
vectorEnabled: Boolean(resolvedPolicy.vectorEnabled),
|
||||
failOpen: Boolean(resolvedPolicy.failOpen),
|
||||
backends,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
policy: resolvedPolicy,
|
||||
getStatus,
|
||||
resolve,
|
||||
write,
|
||||
compact,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user