Merge pull request #1181 from sebslight/plugins/exclusive-slots
Plugins: auto-select exclusive slots
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
resolvePluginInstallDir,
|
resolvePluginInstallDir,
|
||||||
} from "../plugins/install.js";
|
} from "../plugins/install.js";
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
|
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySlotSelectionForPlugin(
|
||||||
|
config: ClawdbotConfig,
|
||||||
|
pluginId: string,
|
||||||
|
): { config: ClawdbotConfig; warnings: string[] } {
|
||||||
|
const report = buildPluginStatusReport({ config });
|
||||||
|
const plugin = report.plugins.find((entry) => 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) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.argument("<id>", "Plugin id")
|
.argument("<id>", "Plugin id")
|
||||||
.action(async (id: string) => {
|
.action(async (id: string) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, id);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: resolved,
|
installPath: resolved,
|
||||||
version: probe.version,
|
version: probe.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
|||||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||||
import { createPluginRuntime } from "./runtime/index.js";
|
import { createPluginRuntime } from "./runtime/index.js";
|
||||||
import { setActivePluginRegistry } from "./runtime.js";
|
import { setActivePluginRegistry } from "./runtime.js";
|
||||||
|
import { defaultSlotIdForKey } from "./slots.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotPluginConfigSchema,
|
ClawdbotPluginConfigSchema,
|
||||||
ClawdbotPluginDefinition,
|
ClawdbotPluginDefinition,
|
||||||
@@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
|
|||||||
deny: normalizeList(config?.deny),
|
deny: normalizeList(config?.deny),
|
||||||
loadPaths: normalizeList(config?.load?.paths),
|
loadPaths: normalizeList(config?.load?.paths),
|
||||||
slots: {
|
slots: {
|
||||||
memory: memorySlot ?? "memory-core",
|
memory: memorySlot ?? defaultSlotIdForKey("memory"),
|
||||||
},
|
},
|
||||||
entries: normalizePluginEntries(config?.entries),
|
entries: normalizePluginEntries(config?.entries),
|
||||||
};
|
};
|
||||||
|
|||||||
96
src/plugins/slots.test.ts
Normal file
96
src/plugins/slots.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/plugins/slots.ts
Normal file
102
src/plugins/slots.ts
Normal file
@@ -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<PluginKind, PluginSlotKey> = {
|
||||||
|
memory: "memory",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user