diff --git a/.gitignore b/.gitignore index 9dc547c9c..91530fe1c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ USER.md # local tooling .serena/ +package-lock.json diff --git a/src/config/schema.ts b/src/config/schema.ts index 1401b0574..f8aa4db40 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -555,7 +555,7 @@ const FIELD_HELP: Record = { "plugins.load.paths": "Additional plugin files or directories to load.", "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", "plugins.slots.memory": - 'Select the active memory plugin by id, or "none" to disable memory plugins.', + 'Select the active memory plugin(s): a single id, an array of ids for stackable plugins, or "none" to disable.', "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index dbe51f38e..c2518e892 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -4,8 +4,13 @@ export type PluginEntryConfig = { }; export type PluginSlotsConfig = { - /** Select which plugin owns the memory slot ("none" disables memory plugins). */ - memory?: string; + /** + * Select which plugin(s) own the memory slot. + * - Single string: "memory-lancedb" - one plugin active + * - Array: ["memory-core", "memory-lancedb"] - multiple plugins active (stackable) + * - "none": disables all memory plugins + */ + memory?: string | string[]; }; export type PluginsLoadConfig = { diff --git a/src/config/validation.ts b/src/config/validation.ts index b9758b65a..563838fb1 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -199,11 +199,21 @@ export function validateConfigObjectWithPlugins(raw: unknown): } const memorySlot = normalizedPlugins.slots.memory; + // Validate memory slot - can be string or array of strings if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) { issues.push({ path: "plugins.slots.memory", message: `plugin not found: ${memorySlot}`, }); + } else if (Array.isArray(memorySlot)) { + for (const slotId of memorySlot) { + if (typeof slotId === "string" && slotId.trim() && !knownIds.has(slotId)) { + issues.push({ + path: "plugins.slots.memory", + message: `plugin not found: ${slotId}`, + }); + } + } } const allowedChannels = new Set(["defaults", ...CHANNEL_IDS]); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 961ba8ecb..fa50d8369 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -495,7 +495,7 @@ export const OpenClawSchema = z .optional(), slots: z .object({ - memory: z.string().optional(), + memory: z.union([z.string(), z.array(z.string())]).optional(), }) .strict() .optional(), diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 9c424a23e..954a8d916 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -8,7 +8,7 @@ export type NormalizedPluginsConfig = { deny: string[]; loadPaths: string[]; slots: { - memory?: string | null; + memory?: string | string[] | null; }; entries: Record; }; @@ -20,7 +20,15 @@ const normalizeList = (value: unknown): string[] => { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); }; -const normalizeSlotValue = (value: unknown): string | null | undefined => { +const normalizeSlotValue = (value: unknown): string | string[] | null | undefined => { + // Handle array of plugin ids (stackable mode) + if (Array.isArray(value)) { + const normalized = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + return normalized.length > 0 ? normalized : undefined; + } + // Handle single string if (typeof value !== "string") return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; @@ -144,7 +152,9 @@ export function resolveEnableState( if (config.allow.length > 0 && !config.allow.includes(id)) { return { enabled: false, reason: "not in allowlist" }; } - if (config.slots.memory === id) { + // Check if plugin is selected in memory slot (handles both string and array) + const memorySlot = config.slots.memory; + if (memorySlot === id || (Array.isArray(memorySlot) && memorySlot.includes(id))) { return { enabled: true }; } const entry = config.entries[id]; @@ -166,13 +176,24 @@ export function resolveEnableState( export function resolveMemorySlotDecision(params: { id: string; kind?: string; - slot: string | null | undefined; + slot: string | string[] | null | undefined; selectedId: string | null; }): { enabled: boolean; reason?: string; selected?: boolean } { if (params.kind !== "memory") return { enabled: true }; if (params.slot === null) { return { enabled: false, reason: "memory slot disabled" }; } + // Handle array of memory plugins (stackable mode) + if (Array.isArray(params.slot)) { + if (params.slot.includes(params.id)) { + return { enabled: true, selected: true }; + } + return { + enabled: false, + reason: `memory slot set to [${params.slot.join(", ")}]`, + }; + } + // Handle single string (exclusive mode - original behavior) if (typeof params.slot === "string") { if (params.slot === params.id) { return { enabled: true, selected: true }; diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index 785bccbad..f8cbee749 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -52,9 +52,15 @@ export function applyExclusiveSlotSelection(params: { }; const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey); + // Format slot value for display (handles both string and array) + const formatSlotValue = (v: string | string[] | null | undefined): string => { + if (v === null || v === undefined) return "none"; + if (Array.isArray(v)) return `[${v.join(", ")}]`; + return v; + }; if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) { warnings.push( - `Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`, + `Exclusive slot "${slotKey}" switched from "${formatSlotValue(inferredPrevSlot)}" to "${params.selectedId}".`, ); }