Files
TencentDB-Agent-Memory/src/config.ts
T
2026-04-09 18:23:46 +08:00

420 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plugin configuration types and parser (v3).
*
* Config is organized into flat functional groups:
* capture, extraction, persona, pipeline, recall, embedding
*
* Minimal config (zero config): {} — all fields have sensible defaults.
*/
// ============================
// Type definitions
// ============================
/** Capture settings — controls L0 conversation recording. */
export interface CaptureConfig {
/** Enable auto-capture (default: true) */
enabled: boolean;
/** Glob patterns to exclude agents (e.g. "bench-judge-*"); matched agents are fully ignored */
excludeAgents: string[];
/**
* L0/L1 local file retention days used as TTL switch.
* 0 means cleanup disabled.(default: 0)
*/
l0l1RetentionDays: number;
/**
* Allow dangerous low retention (1 or 2 days).
* Default false: when disabled, non-zero retention must be >= 3.
*/
allowAggressiveCleanup: boolean;
}
/** Extraction settings (L1) — controls memory extraction from conversations. */
export interface ExtractionConfig {
/** Enable background extraction (default: true) */
enabled: boolean;
/** Enable L1 smart dedup (default: true) */
enableDedup: boolean;
/** Max memories per session (default: 20) */
maxMemoriesPerSession: number;
/** LLM model for extraction, format: "provider/model" (falls back to OpenClaw default model when omitted) */
model?: string;
}
/** Persona (L2/L3) settings — controls scene extraction (L2) and user profile generation (L3). */
export interface PersonaConfig {
/** Trigger persona generation every N new memories (default: 50) */
triggerEveryN: number;
/** Max scene blocks (default: 20) */
maxScenes: number;
/** Persona backup count (default: 3) */
backupCount: number;
/** Scene blocks backup count (default: 10) */
sceneBackupCount: number;
/** LLM model for persona generation, format: "provider/model" (falls back to OpenClaw default model when omitted) */
model?: string;
}
/** Pipeline trigger settings (L1→L2→L3 scheduling). */
export interface PipelineTriggerConfig {
/** Trigger L1 after every N conversation rounds (default: 5) */
everyNConversations: number;
/** Enable warm-up: start threshold at 1, double after each L1 (1→2→4→...→everyN) (default: true) */
enableWarmup: boolean;
/** L1 idle timeout: trigger L1 after this many seconds of inactivity (default: 60) */
l1IdleTimeoutSeconds: number;
/** L2 delay after L1: wait this many seconds after L1 completes before triggering L2 (default: 90) */
l2DelayAfterL1Seconds: number;
/** L2 min interval: minimum seconds between L2 runs per session (default: 300 = 5 min) */
l2MinIntervalSeconds: number;
/** L2 max interval: even without new conversations, trigger L2 at most this often per session (default: 1800 = 30 min) */
l2MaxIntervalSeconds: number;
/** Sessions inactive longer than this (hours) stop L2 polling (default: 24) */
sessionActiveWindowHours: number;
}
/** Recall settings — controls memory retrieval for context injection. */
export interface RecallConfig {
/** Enable auto-recall (default: true) */
enabled: boolean;
/** Max results to return (default: 5) */
maxResults: number;
/** Minimum score threshold (default: 0.3) */
scoreThreshold: number;
/** Search strategy (default: "hybrid") */
strategy: "embedding" | "keyword" | "hybrid";
/** Overall recall timeout in milliseconds (default: 5000). When exceeded, recall is skipped with a warning. */
timeoutMs: number;
}
/** Embedding service configuration for vector search. */
export interface EmbeddingConfig {
/** User-facing default is true in schema, but provider="none" still disables embedding effectively. */
enabled: boolean;
/** Embedding provider: default "none" disables vector search; other values (e.g. "openai", "deepseek") are treated as OpenAI-compatible remote providers. */
provider: string;
/** API Base URL (required for remote provider). */
baseUrl: string;
/** API Key (required for remote provider). */
apiKey: string;
/** Model name (required for remote provider). */
model: string;
/** Vector dimensions (required for remote provider, must match model). */
dimensions: number;
/** Top-K candidates to recall during conflict detection (default: 5) */
conflictRecallTopK: number;
/** Proxy URL for qclaw provider — when provider="qclaw", requests are forwarded through this local proxy */
proxyUrl?: string;
/** Max input text length in characters before truncation (default: 5000). Texts exceeding this limit are truncated with a warning. */
maxInputChars: number;
/** Timeout per embedding API call in milliseconds (default: 10000). */
timeoutMs: number;
/** Internal-only local model cache directory, not exposed in plugin schema. */
modelCacheDir?: string;
/** If set, contains an error message about invalid remote config (embedding is disabled) */
configError?: string;
}
/** Daily cleaner settings for local JSONL data (L0/L1). */
export interface MemoryCleanupConfig {
/** TTL switch from capture.l0l1RetentionDays. Undefined means disabled. */
retentionDays?: number;
/** Whether cleanup is enabled. True only when retentionDays is a valid positive number. */
enabled: boolean;
/** Daily execution time in HH:mm format (default: 03:00). */
cleanTime: string;
}
/** Report settings — controls metric/event reporting. */
export interface ReportConfig {
/** Enable reporting (default: true) */
enabled: boolean;
/** Reporter type: "local" logs structured JSON via logger (default: "local") */
type: string;
}
/** Fully resolved plugin configuration (v3). */
export interface MemoryTdaiConfig {
capture: CaptureConfig;
extraction: ExtractionConfig;
persona: PersonaConfig;
pipeline: PipelineTriggerConfig;
recall: RecallConfig;
embedding: EmbeddingConfig;
memoryCleanup: MemoryCleanupConfig;
report: ReportConfig;
}
// ============================
// Parser
// ============================
/**
* Parse plugin config from raw user input.
* All fields have sensible defaults — minimal config is just {}.
*/
export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTdaiConfig {
const c = raw ?? {};
// --- Capture (L0) ---
const captureGroup = obj(c, "capture");
// --- Retention days validation (from capture.l0l1RetentionDays) ---
const rawRetentionDays = num(captureGroup, "l0l1RetentionDays") ?? 0;
const allowAggressiveCleanup = bool(captureGroup, "allowAggressiveCleanup") ?? false;
let retentionDays: number | undefined;
if (rawRetentionDays <= 0) {
retentionDays = undefined;
} else if (rawRetentionDays >= 3) {
retentionDays = rawRetentionDays;
} else if (allowAggressiveCleanup) {
retentionDays = rawRetentionDays;
} else {
retentionDays = undefined;
}
// --- Extraction (L1) ---
const extractionGroup = obj(c, "extraction");
// --- Persona (L2/L3) ---
const personaGroup = obj(c, "persona");
// --- Pipeline ---
const pipelineGroup = obj(c, "pipeline");
// --- Recall ---
const recallGroup = obj(c, "recall");
// --- Embedding ---
const embeddingGroup = obj(c, "embedding");
let embeddingConfigError: string | undefined;
// Embedding config: determine provider based on user input and apiKey availability
const embeddingApiKey = str(embeddingGroup, "apiKey") ?? "";
const embeddingBaseUrl = str(embeddingGroup, "baseUrl") ?? "";
const embeddingProviderRaw = str(embeddingGroup, "provider") ?? "none";
const embeddingModelRaw = str(embeddingGroup, "model") ?? "";
const embeddingDimensionsRaw = num(embeddingGroup, "dimensions");
const embeddingProxyUrl = str(embeddingGroup, "proxyUrl");
// provider="none" → embedding disabled (default for zero-config users)
// provider="local" → no longer exposed to users; treated as disabled at entry level
// provider="qclaw" → requires proxyUrl for local proxy forwarding
// Any other value → remote mode (requires apiKey, baseUrl, model, dimensions)
let embeddingProvider: string;
let embeddingEnabled = bool(embeddingGroup, "enabled") ?? true;
if (embeddingProviderRaw === "none") {
// Explicitly disabled (default): no embedding, no vector search
embeddingProvider = "none";
embeddingEnabled = false;
} else if (embeddingProviderRaw === "local") {
// Local embedding is not exposed to users; treat as disabled at entry level.
// Internal LocalEmbeddingService code is preserved but not reachable from config.
embeddingProvider = "none";
embeddingEnabled = false;
embeddingConfigError =
"Local embedding provider is not available in user config. " +
"Please configure a remote embedding provider (e.g. openai, deepseek). Embedding has been disabled.";
} else if (embeddingProviderRaw === "qclaw") {
// qclaw provider: requires proxyUrl for local proxy forwarding
const missingFields: string[] = [];
if (!embeddingProxyUrl) missingFields.push("proxyUrl");
if (!embeddingBaseUrl) missingFields.push("baseUrl");
if (!embeddingApiKey) missingFields.push("apiKey");
if (!embeddingModelRaw) missingFields.push("model");
if (embeddingDimensionsRaw == null || embeddingDimensionsRaw <= 0) missingFields.push("dimensions");
if (missingFields.length > 0) {
const errorMsg =
`Embedding provider 'qclaw' requires 'proxyUrl', 'baseUrl', 'apiKey', 'model', and 'dimensions' to be set. ` +
`Missing: ${missingFields.join(", ")}. Embedding has been disabled.`;
embeddingConfigError = errorMsg;
embeddingEnabled = false;
embeddingProvider = embeddingProviderRaw;
} else {
embeddingProvider = embeddingProviderRaw;
}
} else {
// Remote mode — validate all required fields
const missingFields: string[] = [];
if (!embeddingApiKey) missingFields.push("apiKey");
if (!embeddingBaseUrl) missingFields.push("baseUrl");
if (!embeddingModelRaw) missingFields.push("model");
if (embeddingDimensionsRaw == null || embeddingDimensionsRaw <= 0) missingFields.push("dimensions");
if (missingFields.length > 0) {
// Configuration error: disable embedding and log detailed error
// This does NOT throw — the plugin continues running without vector search
const errorMsg =
`Remote embedding provider '${embeddingProviderRaw}' requires 'apiKey', 'baseUrl', 'model', and 'dimensions' to be set. ` +
`Missing: ${missingFields.join(", ")}. Embedding has been disabled.`;
// We store the error message so the caller (index.ts) can log it
embeddingConfigError = errorMsg;
embeddingEnabled = false;
embeddingProvider = embeddingProviderRaw; // preserve original for error context
} else {
embeddingProvider = embeddingProviderRaw;
}
}
// When provider="none", dimensions=0 signals VectorStore to skip vec0 table
// creation entirely (deferred until a real embedding provider is configured).
// This avoids creating vec0 tables with a placeholder dimension that would
// mismatch if the user later enables a different-dimensional provider.
const defaultDimensions =
embeddingProvider === "none" ? 0 :
embeddingDimensionsRaw ?? 0;
const defaultModel = embeddingProvider === "none" ? "" : embeddingModelRaw;
const cleanTime = normalizeCleanTime(str(captureGroup, "cleanTime")) ?? "03:00";
const memoryCleanup: MemoryCleanupConfig = {
retentionDays,
enabled: retentionDays != null,
cleanTime,
};
return {
capture: {
enabled: bool(captureGroup, "enabled") ?? true,
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
l0l1RetentionDays: retentionDays ?? 0,
allowAggressiveCleanup,
},
extraction: {
enabled: bool(extractionGroup, "enabled") ?? true,
enableDedup: bool(extractionGroup, "enableDedup") ?? true,
maxMemoriesPerSession: num(extractionGroup, "maxMemoriesPerSession") ?? 20,
model: optStr(extractionGroup, "model"),
},
persona: {
triggerEveryN: num(personaGroup, "triggerEveryN") ?? 50,
maxScenes: num(personaGroup, "maxScenes") ?? 20,
backupCount: num(personaGroup, "backupCount") ?? 3,
sceneBackupCount: num(personaGroup, "sceneBackupCount") ?? 10,
model: optStr(personaGroup, "model"),
},
pipeline: {
everyNConversations: num(pipelineGroup, "everyNConversations") ?? 5,
enableWarmup: bool(pipelineGroup, "enableWarmup") ?? true,
l1IdleTimeoutSeconds: num(pipelineGroup, "l1IdleTimeoutSeconds") ?? 60,
l2DelayAfterL1Seconds: num(pipelineGroup, "l2DelayAfterL1Seconds") ?? 90,
l2MinIntervalSeconds: num(pipelineGroup, "l2MinIntervalSeconds") ?? 300,
l2MaxIntervalSeconds: num(pipelineGroup, "l2MaxIntervalSeconds") ?? 1800,
sessionActiveWindowHours: num(pipelineGroup, "sessionActiveWindowHours") ?? 24,
},
recall: {
enabled: bool(recallGroup, "enabled") ?? true,
maxResults: num(recallGroup, "maxResults") ?? 5,
scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3,
strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid",
timeoutMs: num(recallGroup, "timeoutMs") ?? 5000,
},
embedding: {
enabled: embeddingEnabled,
provider: embeddingProvider,
baseUrl: embeddingBaseUrl,
apiKey: embeddingApiKey,
model: str(embeddingGroup, "model") ?? defaultModel,
dimensions: num(embeddingGroup, "dimensions") ?? defaultDimensions,
conflictRecallTopK: num(embeddingGroup, "conflictRecallTopK") ?? 5,
proxyUrl: embeddingProxyUrl,
maxInputChars: num(embeddingGroup, "maxInputChars") ?? 5000,
timeoutMs: num(embeddingGroup, "timeoutMs") ?? 10_000,
modelCacheDir: optStr(embeddingGroup, "modelCacheDir"),
configError: embeddingConfigError,
},
memoryCleanup,
report: {
enabled: bool(obj(c, "report"), "enabled") ?? false,
type: str(obj(c, "report"), "type") ?? "local",
},
};
}
// ============================
// Helper functions
// ============================
/** Get sub-object by key, or empty object if missing. */
function obj(c: Record<string, unknown>, key: string): Record<string, unknown> {
const v = c[key];
return v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
}
function str(src: Record<string, unknown>, key: string): string | undefined {
const v = src[key];
return typeof v === "string" && v.trim() ? v.trim() : undefined;
}
function optStr(src: Record<string, unknown>, key: string): string | undefined {
const v = src[key];
return typeof v === "string" ? v : undefined;
}
function num(src: Record<string, unknown>, key: string): number | undefined {
const v = src[key];
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function bool(src: Record<string, unknown>, key: string): boolean | undefined {
const v = src[key];
return typeof v === "boolean" ? v : undefined;
}
function strArray(src: Record<string, unknown>, key: string): string[] | undefined {
const v = src[key];
if (!Array.isArray(v)) return undefined;
return v.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
}
const VALID_STRATEGIES: RecallConfig["strategy"][] = ["embedding", "keyword", "hybrid"];
/**
* Validate recall strategy against whitelist.
* Returns the strategy if valid, undefined otherwise (caller falls back to default).
*/
function validateStrategy(value: string | undefined): RecallConfig["strategy"] | undefined {
if (!value) return undefined;
return VALID_STRATEGIES.includes(value as RecallConfig["strategy"])
? (value as RecallConfig["strategy"])
: undefined;
}
/**
* Normalize a cleanup time string.
*
* The input must follow "HH:MM" or "H:MM" format (24-hour clock).
* If the time is valid, it returns the normalized format "HH:MM"
* with leading zeros added when necessary.
* If the format is invalid or the time is out of range
* (hour: 023, minute: 059), it returns undefined.
*
* Examples:
* normalizeCleanTime("3:05") -> "03:05"
* normalizeCleanTime("03:05") -> "03:05"
* normalizeCleanTime("23:59") -> "23:59"
*
* normalizeCleanTime("24:00") -> undefined // hour out of range
* normalizeCleanTime("12:60") -> undefined // minute out of range
* normalizeCleanTime("3:5") -> undefined // minute must have two digits
* normalizeCleanTime("abc") -> undefined // invalid format
*/
function normalizeCleanTime(input: string | undefined): string | undefined {
if (!input) return undefined;
const trimmed = input.trim();
const m = /^(\d{1,2}):(\d{2})$/.exec(trimmed);
if (!m) return undefined;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isInteger(hh) || !Number.isInteger(mm)) return undefined;
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return undefined;
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
}