Files
memind/memory-v2-admin-config.mjs

397 lines
16 KiB
JavaScript

import crypto from 'node:crypto';
import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs';
const CONFIG_TABLE = 'h5_memory_v2_admin_config';
const CONFIG_SCOPE = 'global';
const FIELD_SPECS = [
{ env: 'MEMORY_ENABLED', group: 'global', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_BACKEND', group: 'global', field: 'backend', type: 'string' },
{ env: 'MEMORY_PROFILE_ENABLED', group: 'global', field: 'profileEnabled', type: 'boolean' },
{ env: 'MEMORY_EVENT_LOG_ENABLED', group: 'global', field: 'eventLogEnabled', type: 'boolean' },
{ env: 'MEMORY_VECTOR_ENABLED', group: 'global', field: 'vectorEnabled', type: 'boolean' },
{ env: 'MEMORY_FAIL_OPEN', group: 'global', field: 'failOpen', type: 'boolean' },
{ env: 'MEMIND_CHAT_LLM_ROUTER_ENABLED', group: 'chatIntentRouter', field: 'enabled', type: 'boolean' },
{ env: 'MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID', group: 'chatIntentRouter', field: 'modelProviderKeyId', type: 'string' },
{ env: 'MEMIND_CHAT_ROUTER_MODEL', group: 'chatIntentRouter', field: 'model', type: 'string' },
{ env: 'MEMIND_CHAT_ROUTER_MODEL_API', group: 'chatIntentRouter', field: 'modelApiType', type: 'string' },
{ env: 'MEMIND_CHAT_ROUTER_MIN_CONFIDENCE', group: 'chatIntentRouter', field: 'minConfidence', type: 'number' },
{ env: 'MEMIND_CHAT_ROUTER_MEMORY_ENABLED', group: 'chatIntentRouter', field: 'memoryResolveEnabled', type: 'boolean' },
{ env: 'MEMIND_CHAT_ROUTER_MEMORY_LIMIT', group: 'chatIntentRouter', field: 'memoryResolveLimit', type: 'number' },
{ env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' },
{ env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' },
{ env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' },
{ env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' },
{ env: 'MEMORY_PGVECTOR_EMBEDDING_MODULE', group: 'pgvector', field: 'embeddingModule', type: 'string' },
{ env: 'MEMORY_PGVECTOR_POOL_MAX', group: 'pgvector', field: 'poolMax', type: 'number' },
{ env: 'MEMORY_PGVECTOR_LIMIT', group: 'pgvector', field: 'limit', type: 'number' },
{ env: 'MEMORY_QDRANT_ENABLED', group: 'qdrant', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_QDRANT_URL', group: 'qdrant', field: 'url', type: 'string' },
{ env: 'MEMORY_QDRANT_COLLECTION', group: 'qdrant', field: 'collection', type: 'string' },
{ env: 'MEMORY_QDRANT_EMBEDDING_MODULE', group: 'qdrant', field: 'embeddingModule', type: 'string' },
{ env: 'MEMORY_QDRANT_API_KEY', group: 'qdrant', field: 'apiKey', type: 'secret' },
{ env: 'MEMORY_QDRANT_TIMEOUT_MS', group: 'qdrant', field: 'timeoutMs', type: 'number' },
{ env: 'MEMORY_QDRANT_LIMIT', group: 'qdrant', field: 'limit', type: 'number' },
{ env: 'MEMORY_WEAVIATE_ENABLED', group: 'weaviate', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_WEAVIATE_URL', group: 'weaviate', field: 'url', type: 'string' },
{ env: 'MEMORY_WEAVIATE_COLLECTION', group: 'weaviate', field: 'collection', type: 'string' },
{ env: 'MEMORY_WEAVIATE_EMBEDDING_MODULE', group: 'weaviate', field: 'embeddingModule', type: 'string' },
{ env: 'MEMORY_WEAVIATE_API_KEY', group: 'weaviate', field: 'apiKey', type: 'secret' },
{ env: 'MEMORY_WEAVIATE_TIMEOUT_MS', group: 'weaviate', field: 'timeoutMs', type: 'number' },
{ env: 'MEMORY_WEAVIATE_LIMIT', group: 'weaviate', field: 'limit', type: 'number' },
{ env: 'MEMORY_MEM0_ENABLED', group: 'mem0', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_MEM0_API_KEY', group: 'mem0', field: 'apiKey', type: 'secret' },
{ env: 'MEMORY_MEM0_PROJECT_ID', group: 'mem0', field: 'projectId', type: 'string' },
{ env: 'MEMORY_MEM0_BASE_URL', group: 'mem0', field: 'baseUrl', type: 'string' },
{ env: 'MEMORY_MEM0_WRITE_PATH', group: 'mem0', field: 'writePath', type: 'string' },
{ env: 'MEMORY_MEM0_COMPACT_PATH', group: 'mem0', field: 'compactPath', type: 'string' },
{ env: 'MEMORY_MEM0_MODEL_PROVIDER_KEY_ID', group: 'mem0', field: 'modelProviderKeyId', type: 'string' },
{ env: 'MEMORY_MEM0_MODEL', group: 'mem0', field: 'model', type: 'string' },
{ env: 'MEMORY_MEM0_MODEL_API', group: 'mem0', field: 'modelApiType', type: 'string' },
{ env: 'MEMORY_MEM0_TIMEOUT_MS', group: 'mem0', field: 'timeoutMs', type: 'number' },
{ env: 'MEMORY_LETTA_ENABLED', group: 'letta', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_LETTA_API_KEY', group: 'letta', field: 'apiKey', type: 'secret' },
{ env: 'MEMORY_LETTA_PROJECT_ID', group: 'letta', field: 'projectId', type: 'string' },
{ env: 'MEMORY_LETTA_AGENT_ID', group: 'letta', field: 'agentId', type: 'string' },
{ env: 'MEMORY_LETTA_BASE_URL', group: 'letta', field: 'baseUrl', type: 'string' },
{ env: 'MEMORY_LETTA_RESOLVE_PATH', group: 'letta', field: 'resolvePath', type: 'string' },
{ env: 'MEMORY_LETTA_WRITE_PATH', group: 'letta', field: 'writePath', type: 'string' },
{ env: 'MEMORY_LETTA_COMPACT_PATH', group: 'letta', field: 'compactPath', type: 'string' },
{ env: 'MEMORY_LETTA_MODEL_PROVIDER_KEY_ID', group: 'letta', field: 'modelProviderKeyId', type: 'string' },
{ env: 'MEMORY_LETTA_MODEL', group: 'letta', field: 'model', type: 'string' },
{ env: 'MEMORY_LETTA_MODEL_API', group: 'letta', field: 'modelApiType', type: 'string' },
{ env: 'MEMORY_LETTA_TIMEOUT_MS', group: 'letta', field: 'timeoutMs', type: 'number' },
{ env: 'MEMORY_NEO4J_ENABLED', group: 'neo4j', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_NEO4J_URI', group: 'neo4j', field: 'uri', type: 'string' },
{ env: 'MEMORY_NEO4J_HTTP_URL', group: 'neo4j', field: 'httpUrl', type: 'string' },
{ env: 'MEMORY_NEO4J_USER', group: 'neo4j', field: 'user', type: 'string' },
{ env: 'MEMORY_NEO4J_PASSWORD', group: 'neo4j', field: 'password', type: 'secret' },
{ env: 'MEMORY_NEO4J_DATABASE', group: 'neo4j', field: 'database', type: 'string' },
{ env: 'MEMORY_NEO4J_TIMEOUT_MS', group: 'neo4j', field: 'timeoutMs', type: 'number' },
{ env: 'MEMORY_REDIS_STREAMS_ENABLED', group: 'redisStreams', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_REDIS_STREAMS_URL', group: 'redisStreams', field: 'url', type: 'string' },
{ env: 'MEMORY_REDIS_STREAMS_STREAM', group: 'redisStreams', field: 'stream', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_ENABLED', group: 'langgraph', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_LANGGRAPH_URL', group: 'langgraph', field: 'url', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_POLICY_ID', group: 'langgraph', field: 'policyId', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_API_KEY', group: 'langgraph', field: 'apiKey', type: 'secret' },
{ env: 'MEMORY_LANGGRAPH_RESOLVE_PATH', group: 'langgraph', field: 'resolvePath', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_MODEL_PROVIDER_KEY_ID', group: 'langgraph', field: 'modelProviderKeyId', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_MODEL', group: 'langgraph', field: 'model', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_MODEL_API', group: 'langgraph', field: 'modelApiType', type: 'string' },
{ env: 'MEMORY_LANGGRAPH_TIMEOUT_MS', group: 'langgraph', field: 'timeoutMs', type: 'number' },
];
const GROUPS = [
'global',
'chatIntentRouter',
'pgvector',
'qdrant',
'weaviate',
'mem0',
'letta',
'neo4j',
'redisStreams',
'langgraph',
];
function defaultConfigShape() {
return Object.fromEntries(GROUPS.map((group) => [group, {}]));
}
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function normalizeNumber(value) {
if (value == null || value === '') return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? String(parsed) : '';
}
function normalizeString(value) {
if (value == null) return '';
return String(value).trim();
}
function normalizeSecretPatch(value) {
if (value === undefined) return undefined;
if (value === null) return '';
return String(value);
}
function cloneConfig(config = null) {
return structuredClone?.(config ?? defaultConfigShape())
?? JSON.parse(JSON.stringify(config ?? defaultConfigShape()));
}
function parseJsonLike(value, fallback) {
if (value == null || value === '') return fallback;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
if (typeof value === 'object') return value;
return fallback;
}
function flattenConfig(config, secrets) {
const env = {};
for (const spec of FIELD_SPECS) {
const value = spec.type === 'secret'
? secrets?.[spec.group]?.[spec.field]
: config?.[spec.group]?.[spec.field];
if (spec.type === 'boolean') {
env[spec.env] = normalizeBoolean(value, false) ? '1' : '0';
} else if (spec.type === 'number') {
env[spec.env] = normalizeNumber(value);
} else {
env[spec.env] = normalizeString(value);
}
}
if (!env.MEMORY_PGVECTOR_ENABLED) {
env.MEMORY_PGVECTOR_ENABLED = env.MEMORY_VECTOR_ENABLED;
}
return env;
}
function applyEnv(config, secrets, env = process.env) {
for (const spec of FIELD_SPECS) {
const raw = env?.[spec.env];
if (spec.type === 'secret') {
if (raw != null && String(raw).trim() !== '') {
secrets[spec.group][spec.field] = String(raw).trim();
}
continue;
}
if (spec.type === 'boolean') {
config[spec.group][spec.field] = normalizeBoolean(raw, Boolean(config[spec.group][spec.field]));
} else if (spec.type === 'number') {
const normalized = normalizeNumber(raw);
if (normalized !== '') config[spec.group][spec.field] = normalized;
} else if (raw != null && String(raw).trim() !== '') {
config[spec.group][spec.field] = String(raw).trim();
}
}
if (config.pgvector.enabled === undefined) {
config.pgvector.enabled = normalizeBoolean(env?.MEMORY_VECTOR_ENABLED, false);
}
if (env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED == null || String(env.MEMIND_CHAT_ROUTER_MEMORY_ENABLED).trim() === '') {
config.chatIntentRouter.memoryResolveEnabled = true;
}
if (!normalizeString(config.chatIntentRouter.modelApiType)) {
config.chatIntentRouter.modelApiType = 'chat';
}
if (!normalizeString(config.chatIntentRouter.minConfidence)) {
config.chatIntentRouter.minConfidence = '0.65';
}
if (!normalizeString(config.chatIntentRouter.memoryResolveLimit)) {
config.chatIntentRouter.memoryResolveLimit = '8';
}
if (!normalizeString(config.chatIntentRouter.timeoutMs)) {
config.chatIntentRouter.timeoutMs = '2500';
}
if (config.chatIntentRouter.enabled === undefined) {
config.chatIntentRouter.enabled = false;
}
const fallbackRoute = normalizeString(config.chatIntentRouter.fallbackRoute);
if (!fallbackRoute || fallbackRoute === 'direct_chat') {
config.chatIntentRouter.fallbackRoute = 'agent_orchestration';
}
return { config, secrets };
}
function buildMaskedView(config, secrets) {
const view = cloneConfig(config);
for (const spec of FIELD_SPECS) {
if (spec.type !== 'secret') continue;
const raw = normalizeString(secrets?.[spec.group]?.[spec.field]);
view[spec.group][`${spec.field}Configured`] = raw.length > 0;
view[spec.group][`${spec.field}Masked`] = raw ? maskApiKey(raw) : '';
}
return view;
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_scope VARCHAR(32) PRIMARY KEY,
config_json JSON NOT NULL,
secrets_ciphertext MEDIUMTEXT NULL,
secrets_iv VARCHAR(255) NULL,
secrets_tag VARCHAR(255) NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function hashState(config, secrets) {
return crypto
.createHash('sha256')
.update(JSON.stringify({ config, secrets }))
.digest('hex');
}
async function loadStoredState(pool) {
await ensureConfigTable(pool);
const [rows] = await pool.query(
`SELECT config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_scope = ?
LIMIT 1`,
[CONFIG_SCOPE],
);
const row = rows[0];
if (!row) return null;
let secrets = defaultConfigShape();
if (row.secrets_ciphertext && row.secrets_iv && row.secrets_tag) {
secrets = {
...defaultConfigShape(),
...parseJsonLike(
decryptSecret(
{
ciphertext: row.secrets_ciphertext,
iv: row.secrets_iv,
tag: row.secrets_tag,
},
),
{},
),
};
}
return {
config: { ...defaultConfigShape(), ...parseJsonLike(row.config_json, {}) },
secrets,
updatedAt: Number(row.updated_at ?? 0) || null,
updatedBy: row.updated_by ?? null,
};
}
function mergePatch(currentConfig, currentSecrets, patch = {}) {
const nextConfig = cloneConfig(currentConfig);
const nextSecrets = cloneConfig(currentSecrets);
for (const spec of FIELD_SPECS) {
const section = patch?.[spec.group];
if (!section || !(spec.field in section)) continue;
if (spec.type === 'secret') {
const value = normalizeSecretPatch(section[spec.field]);
if (value === undefined) continue;
nextSecrets[spec.group][spec.field] = normalizeString(value);
continue;
}
const value = section[spec.field];
if (spec.type === 'boolean') {
nextConfig[spec.group][spec.field] = normalizeBoolean(value, false);
} else if (spec.type === 'number') {
nextConfig[spec.group][spec.field] = normalizeNumber(value);
} else {
nextConfig[spec.group][spec.field] = normalizeString(value);
}
}
return { nextConfig, nextSecrets };
}
export function createMemoryV2AdminConfigService(pool, { env = process.env } = {}) {
async function loadState() {
const defaults = applyEnv(defaultConfigShape(), defaultConfigShape(), env);
const stored = await loadStoredState(pool);
if (!stored) {
return {
config: defaults.config,
secrets: defaults.secrets,
updatedAt: null,
updatedBy: null,
};
}
return {
config: { ...defaults.config, ...stored.config },
secrets: { ...defaults.secrets, ...stored.secrets },
updatedAt: stored.updatedAt,
updatedBy: stored.updatedBy,
};
}
return {
async getAdminConfig() {
const state = await loadState();
return {
config: buildMaskedView(state.config, state.secrets),
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
};
},
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
const state = await loadState();
const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch);
await ensureConfigTable(pool);
const now = Date.now();
const encryptedSecrets = encryptSecret(JSON.stringify(nextSecrets));
await pool.query(
`INSERT INTO ${CONFIG_TABLE}
(config_scope, config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
secrets_ciphertext = VALUES(secrets_ciphertext),
secrets_iv = VALUES(secrets_iv),
secrets_tag = VALUES(secrets_tag),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[
CONFIG_SCOPE,
JSON.stringify(nextConfig),
encryptedSecrets.ciphertext,
encryptedSecrets.iv,
encryptedSecrets.tag,
updatedBy,
now,
],
);
return this.getAdminConfig();
},
async getRuntimeState() {
const state = await loadState();
const overrides = flattenConfig(state.config, state.secrets);
return {
source: 'admin-db',
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
fingerprint: `${state.updatedAt ?? 'env'}:${hashState(state.config, state.secrets)}`,
overrides,
};
},
};
}
export const memoryV2AdminConfigInternals = {
CONFIG_SCOPE,
CONFIG_TABLE,
FIELD_SPECS,
defaultConfigShape,
flattenConfig,
applyEnv,
mergePatch,
buildMaskedView,
};