diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index f9443d4e6..1b0683a3f 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -13,6 +13,7 @@ import { resolvePluginInstallDir, } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; +import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import type { PluginRecord } from "../plugins/registry.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; @@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise entry.id === pluginId); + if (!plugin) { + return { config, warnings: [] }; + } + const result = applyExclusiveSlotSelection({ + config, + selectedId: plugin.id, + selectedKind: plugin.kind, + registry: report, + }); + return { config: result.config, warnings: result.warnings }; +} + +function logSlotWarnings(warnings: string[]) { + if (warnings.length === 0) return; + for (const warning of warnings) { + defaultRuntime.log(chalk.yellow(warning)); + } +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) { .argument("", "Plugin id") .action(async (id: string) => { const cfg = loadConfig(); - const next = { + let next: ClawdbotConfig = { ...cfg, plugins: { ...cfg.plugins, @@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) { }, }, }; + const slotResult = applySlotSelectionForPlugin(next, id); + next = slotResult.config; await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); }); @@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) { installPath: resolved, version: probe.version, }); + const slotResult = applySlotSelectionForPlugin(next, probe.pluginId); + next = slotResult.config; await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); defaultRuntime.log(`Linked plugin path: ${resolved}`); defaultRuntime.log(`Restart the gateway to load plugins.`); return; @@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) { installPath: result.targetDir, version: result.version, }); + const slotResult = applySlotSelectionForPlugin(next, result.pluginId); + next = slotResult.config; await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); return; @@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) { installPath: result.targetDir, version: result.version, }); + const slotResult = applySlotSelectionForPlugin(next, result.pluginId); + next = slotResult.config; await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5d03bd50a..31ee538eb 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -12,6 +12,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRuntime } from "./runtime/index.js"; import { setActivePluginRegistry } from "./runtime.js"; +import { defaultSlotIdForKey } from "./slots.js"; import type { ClawdbotPluginConfigSchema, ClawdbotPluginDefinition, @@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP deny: normalizeList(config?.deny), loadPaths: normalizeList(config?.load?.paths), slots: { - memory: memorySlot ?? "memory-core", + memory: memorySlot ?? defaultSlotIdForKey("memory"), }, entries: normalizePluginEntries(config?.entries), }; diff --git a/src/plugins/slots.test.ts b/src/plugins/slots.test.ts new file mode 100644 index 000000000..c2a165dea --- /dev/null +++ b/src/plugins/slots.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { applyExclusiveSlotSelection } from "./slots.js"; + +describe("applyExclusiveSlotSelection", () => { + it("selects the slot and disables other entries for the same kind", () => { + const config: ClawdbotConfig = { + plugins: { + slots: { memory: "memory-core" }, + entries: { + "memory-core": { enabled: true }, + memory: { enabled: true }, + }, + }, + }; + + const result = applyExclusiveSlotSelection({ + config, + selectedId: "memory", + selectedKind: "memory", + registry: { + plugins: [ + { id: "memory-core", kind: "memory" }, + { id: "memory", kind: "memory" }, + ], + }, + }); + + expect(result.changed).toBe(true); + expect(result.config.plugins?.slots?.memory).toBe("memory"); + expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false); + expect(result.warnings).toContain( + 'Exclusive slot "memory" switched from "memory-core" to "memory".', + ); + expect(result.warnings).toContain( + 'Disabled other "memory" slot plugins: memory-core.', + ); + }); + + it("does nothing when the slot already matches", () => { + const config: ClawdbotConfig = { + plugins: { + slots: { memory: "memory" }, + entries: { + memory: { enabled: true }, + }, + }, + }; + + const result = applyExclusiveSlotSelection({ + config, + selectedId: "memory", + selectedKind: "memory", + registry: { plugins: [{ id: "memory", kind: "memory" }] }, + }); + + expect(result.changed).toBe(false); + expect(result.warnings).toHaveLength(0); + expect(result.config).toBe(config); + }); + + it("warns when the slot falls back to a default", () => { + const config: ClawdbotConfig = { + plugins: { + entries: { + memory: { enabled: true }, + }, + }, + }; + + const result = applyExclusiveSlotSelection({ + config, + selectedId: "memory", + selectedKind: "memory", + registry: { plugins: [{ id: "memory", kind: "memory" }] }, + }); + + expect(result.changed).toBe(true); + expect(result.warnings).toContain( + 'Exclusive slot "memory" switched from "memory-core" to "memory".', + ); + }); + + it("skips changes when no exclusive slot applies", () => { + const config: ClawdbotConfig = {}; + const result = applyExclusiveSlotSelection({ + config, + selectedId: "custom", + }); + + expect(result.changed).toBe(false); + expect(result.warnings).toHaveLength(0); + expect(result.config).toBe(config); + }); +}); diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts new file mode 100644 index 000000000..7762b5ccd --- /dev/null +++ b/src/plugins/slots.ts @@ -0,0 +1,102 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { PluginSlotsConfig } from "../config/types.plugins.js"; +import type { PluginKind } from "./types.js"; + +export type PluginSlotKey = keyof PluginSlotsConfig; + +type SlotPluginRecord = { + id: string; + kind?: PluginKind; +}; + +const SLOT_BY_KIND: Record = { + memory: "memory", +}; + +const DEFAULT_SLOT_BY_KEY: Record = { + memory: "memory-core", +}; + +export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null { + if (!kind) return null; + return SLOT_BY_KIND[kind] ?? null; +} + +export function defaultSlotIdForKey(slotKey: PluginSlotKey): string { + return DEFAULT_SLOT_BY_KEY[slotKey]; +} + +export type SlotSelectionResult = { + config: ClawdbotConfig; + warnings: string[]; + changed: boolean; +}; + +export function applyExclusiveSlotSelection(params: { + config: ClawdbotConfig; + selectedId: string; + selectedKind?: PluginKind; + registry?: { plugins: SlotPluginRecord[] }; +}): SlotSelectionResult { + const slotKey = slotKeyForPluginKind(params.selectedKind); + if (!slotKey) { + return { config: params.config, warnings: [], changed: false }; + } + + const warnings: string[] = []; + const pluginsConfig = params.config.plugins ?? {}; + const prevSlot = pluginsConfig.slots?.[slotKey]; + const slots = { + ...(pluginsConfig.slots ?? {}), + [slotKey]: params.selectedId, + }; + + const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey); + if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) { + warnings.push( + `Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`, + ); + } + + const entries = { ...(pluginsConfig.entries ?? {}) }; + const disabledIds: string[] = []; + if (params.registry) { + for (const plugin of params.registry.plugins) { + if (plugin.id === params.selectedId) continue; + if (plugin.kind !== params.selectedKind) continue; + const entry = entries[plugin.id]; + if (!entry || entry.enabled !== false) { + entries[plugin.id] = { + ...(entry ?? {}), + enabled: false, + }; + disabledIds.push(plugin.id); + } + } + } + + if (disabledIds.length > 0) { + warnings.push( + `Disabled other "${slotKey}" slot plugins: ${disabledIds.sort().join(", ")}.`, + ); + } + + const changed = prevSlot !== params.selectedId || disabledIds.length > 0; + + if (!changed) { + return { config: params.config, warnings: [], changed: false }; + } + + return { + config: { + ...params.config, + plugins: { + ...pluginsConfig, + slots, + entries, + }, + }, + warnings, + changed: true, + }; +}