feat(plugins): allow stackable memory plugins

Adds support for multiple memory plugins to run simultaneously.

Previously, memory plugins were mutually exclusive - only one could
be active at a time via the plugins.slots.memory config.

Now, plugins.slots.memory can accept:
- Single string: "memory-lancedb" (original behavior)
- Array: ["memory-core", "memory-lancedb"] (stackable mode)
- "none": disable all memory plugins

Use case: Run memory-core for workspace file search alongside
memory-lancedb for auto-recall/capture conversation memory.

Changes:
- types.plugins.ts: Updated PluginSlotsConfig type
- zod-schema.ts: Updated schema to accept string | string[]
- config-state.ts: Updated normalization and slot decision logic
- validation.ts: Updated validation to check array entries
- schema.ts: Updated help text
This commit is contained in:
Tejesh Priyatham 2026-01-26 17:36:50 +00:00 committed by Neo
parent 2044b3ca8d
commit 344f5abcb5
5 changed files with 44 additions and 8 deletions

View File

@ -551,7 +551,7 @@ const FIELD_HELP: Record<string, string> = {
"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).",

View File

@ -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 = {

View File

@ -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<string>(["defaults", ...CHANNEL_IDS]);

View File

@ -493,7 +493,7 @@ export const ClawdbotSchema = z
.optional(),
slots: z
.object({
memory: z.string().optional(),
memory: z.union([z.string(), z.array(z.string())]).optional(),
})
.strict()
.optional(),

View File

@ -8,7 +8,7 @@ export type NormalizedPluginsConfig = {
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
memory?: string | string[] | null;
};
entries: Record<string, { enabled?: boolean; config?: unknown }>;
};
@ -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;
@ -78,7 +86,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];
@ -100,13 +110,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 };