471 lines
22 KiB
JavaScript
471 lines
22 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_CANDIDATE_ENABLED', group: 'candidateMemory', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_CANDIDATE_MODE', group: 'candidateMemory', field: 'mode', type: 'string' },
|
|
{ env: 'MEMORY_CANDIDATE_MIN_IMPORTANCE', group: 'candidateMemory', field: 'minImportance', type: 'number' },
|
|
{ env: 'MEMORY_CANDIDATE_MIN_CONFIDENCE', group: 'candidateMemory', field: 'minConfidence', type: 'number' },
|
|
{ env: 'MEMORY_CANDIDATE_MAX_PENDING', group: 'candidateMemory', field: 'maxPending', type: 'number' },
|
|
{ env: 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', group: 'candidateMemory', field: 'persistenceEnabled', type: 'boolean' },
|
|
|
|
{ env: 'MEMORY_AGENT_RESOLVE_ENABLED', group: 'runtimeControl', field: 'agentResolveEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_AGENT_INJECTION_MODE', group: 'runtimeControl', field: 'agentInjectionMode', type: 'string' },
|
|
{ env: 'MEMORY_AGENT_CANARY_USER_IDS', group: 'runtimeControl', field: 'agentCanaryUserIds', type: 'string' },
|
|
{ env: 'MEMORY_AGENT_RESOLVE_LIMIT', group: 'runtimeControl', field: 'agentResolveLimit', type: 'number' },
|
|
{ env: 'MEMORY_AGENT_RESOLVE_TIMEOUT_MS', group: 'runtimeControl', field: 'agentResolveTimeoutMs', type: 'number' },
|
|
{ env: 'MEMORY_PROMOTION_ENABLED', group: 'runtimeControl', field: 'promotionEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_COMPACTION_V2_ENABLED', group: 'runtimeControl', field: 'compactionV2Enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_REFLECTION_ENABLED', group: 'runtimeControl', field: 'reflectionEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_WORKER_ENABLED', group: 'runtimeControl', field: 'lifecycleWorkerEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_ROLLOUT_MODE', group: 'runtimeControl', field: 'lifecycleRolloutMode', type: 'string' },
|
|
{ env: 'MEMORY_LIFECYCLE_ROLLOUT_USER_IDS', group: 'runtimeControl', field: 'lifecycleRolloutUserIds', type: 'string' },
|
|
|
|
{ env: 'MEMORY_POLICY_ENABLED', group: 'policy', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_POLICY_SAVE_EXPLICIT', group: 'policy', field: 'saveExplicit', type: 'boolean' },
|
|
{ env: 'MEMORY_POLICY_REJECT_SENSITIVE', group: 'policy', field: 'rejectSensitive', type: 'boolean' },
|
|
{ env: 'MEMORY_POLICY_REQUIRE_EVIDENCE', group: 'policy', field: 'requireEvidence', type: 'boolean' },
|
|
{ env: 'MEMORY_POLICY_RETENTION_DAYS', group: 'policy', field: 'retentionDays', type: 'number' },
|
|
|
|
{ env: 'MEMORY_RETRIEVER_ENABLED', group: 'retriever', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_RETRIEVER_EPISODIC_ENABLED', group: 'retriever', field: 'episodicEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_RETRIEVER_SEMANTIC_ENABLED', group: 'retriever', field: 'semanticEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_RETRIEVER_PREFERENCE_ENABLED', group: 'retriever', field: 'preferenceEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_RETRIEVER_GOAL_ENABLED', group: 'retriever', field: 'goalEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_RETRIEVER_LIMIT', group: 'retriever', field: 'limit', type: 'number' },
|
|
{ env: 'MEMORY_RETRIEVER_TOKEN_BUDGET', group: 'retriever', field: 'tokenBudget', type: 'number' },
|
|
{ env: 'MEMORY_RETRIEVER_TIMEOUT_MS', group: 'retriever', field: 'timeoutMs', type: 'number' },
|
|
|
|
{ env: 'MEMORY_LIFECYCLE_ENABLED', group: 'lifecycle', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_DEDUPE_ENABLED', group: 'lifecycle', field: 'dedupeEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_CONFLICT_REVIEW', group: 'lifecycle', field: 'conflictReview', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_DECAY_ENABLED', group: 'lifecycle', field: 'decayEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_FORGETTING_ENABLED', group: 'lifecycle', field: 'forgettingEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_LIFECYCLE_COMPACT_INTERVAL_HOURS', group: 'lifecycle', field: 'compactIntervalHours', type: 'number' },
|
|
|
|
{ env: 'MEMORY_PERSONA_ENABLED', group: 'persona', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_PERSONA_PROVIDER', group: 'persona', field: 'provider', type: 'string' },
|
|
{ env: 'MEMORY_PERSONA_SHADOW_MODE', group: 'persona', field: 'shadowMode', type: 'boolean' },
|
|
{ env: 'MEMORY_PERSONA_MAX_TOKENS', group: 'persona', field: 'maxTokens', type: 'number' },
|
|
{ env: 'MEMORY_PERSONA_CACHE_TTL_SECONDS', group: 'persona', field: 'cacheTtlSeconds', type: 'number' },
|
|
|
|
{ env: 'MEMORY_GRAPH_ENABLED', group: 'graph', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_GRAPH_PROVIDER', group: 'graph', field: 'provider', type: 'string' },
|
|
{ env: 'MEMORY_GRAPH_MAX_DEPTH', group: 'graph', field: 'maxDepth', type: 'number' },
|
|
{ env: 'MEMORY_GRAPH_RELATION_LIMIT', group: 'graph', field: 'relationLimit', type: 'number' },
|
|
|
|
{ env: 'MEMORY_USER_MANAGEMENT_ENABLED', group: 'userMemory', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_USER_REVIEW_ENABLED', group: 'userMemory', field: 'reviewEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_USER_CORRECTION_ENABLED', group: 'userMemory', field: 'correctionEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_USER_PIN_ENABLED', group: 'userMemory', field: 'pinEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_USER_FORGET_ENABLED', group: 'userMemory', field: 'forgetEnabled', type: 'boolean' },
|
|
{ env: 'MEMORY_USER_DELETE_PROPAGATION', group: 'userMemory', field: 'deletePropagation', type: 'boolean' },
|
|
|
|
{ env: 'MEMORY_PLUGIN_HEALTH_ENABLED', group: 'pluginHealth', field: 'enabled', type: 'boolean' },
|
|
{ env: 'MEMORY_PLUGIN_HEALTH_INTERVAL_SECONDS', group: 'pluginHealth', field: 'intervalSeconds', type: 'number' },
|
|
{ env: 'MEMORY_PLUGIN_HEALTH_TIMEOUT_MS', group: 'pluginHealth', field: 'timeoutMs', type: 'number' },
|
|
{ env: 'MEMORY_PLUGIN_HEALTH_FAILURE_THRESHOLD', group: 'pluginHealth', field: 'failureThreshold', type: 'number' },
|
|
{ env: 'MEMORY_PLUGIN_HEALTH_AUTO_FALLBACK', group: 'pluginHealth', field: 'autoFallback', type: 'boolean' },
|
|
|
|
{ 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',
|
|
'candidateMemory',
|
|
'runtimeControl',
|
|
'policy',
|
|
'retriever',
|
|
'lifecycle',
|
|
'persona',
|
|
'graph',
|
|
'userMemory',
|
|
'pluginHealth',
|
|
'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,
|
|
};
|