feat(plugins): add memory slot plugin

This commit is contained in:
Peter Steinberger
2026-01-18 02:12:01 +00:00
parent 005b831023
commit 9fd9f4c896
15 changed files with 244 additions and 14 deletions

View File

@@ -74,6 +74,31 @@ describe("loadClawdbotPlugins", () => {
const enabled = enabledRegistry.plugins.find((entry) => entry.id === "bundled");
expect(enabled?.status).toBe("loaded");
});
it("enables bundled memory plugin when selected by slot", () => {
const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "memory-core.ts");
fs.writeFileSync(
bundledPath,
'export default { id: "memory-core", kind: "memory", register() {} };',
"utf-8",
);
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
slots: {
memory: "memory-core",
},
},
},
});
const memory = registry.plugins.find((entry) => entry.id === "memory-core");
expect(memory?.status).toBe("loaded");
});
it("loads plugins from config paths", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
@@ -237,6 +262,54 @@ describe("loadClawdbotPlugins", () => {
expect(disabled?.status).toBe("disabled");
});
it("enforces memory slot selection", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memoryA = writePlugin({
id: "memory-a",
body: `export default { id: "memory-a", kind: "memory", register() {} };`,
});
const memoryB = writePlugin({
id: "memory-b",
body: `export default { id: "memory-b", kind: "memory", register() {} };`,
});
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
load: { paths: [memoryA.file, memoryB.file] },
slots: { memory: "memory-b" },
},
},
});
const a = registry.plugins.find((entry) => entry.id === "memory-a");
const b = registry.plugins.find((entry) => entry.id === "memory-b");
expect(b?.status).toBe("loaded");
expect(a?.status).toBe("disabled");
});
it("disables memory plugins when slot is none", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memory = writePlugin({
id: "memory-off",
body: `export default { id: "memory-off", kind: "memory", register() {} };`,
});
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
load: { paths: [memory.file] },
slots: { memory: "none" },
},
},
});
const entry = registry.plugins.find((item) => item.id === "memory-off");
expect(entry?.status).toBe("disabled");
});
it("prefers higher-precedence plugins with the same id", () => {
const bundledDir = makeTempDir();
fs.writeFileSync(path.join(bundledDir, "shadow.js"), "export default function () {}", "utf-8");

View File

@@ -31,6 +31,9 @@ type NormalizedPluginsConfig = {
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
@@ -43,6 +46,14 @@ const normalizeList = (value: unknown): string[] => {
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "none") return null;
return trimmed;
};
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
@@ -67,11 +78,15 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
};
const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? "memory-core",
},
entries: normalizePluginEntries(config?.entries),
};
};
@@ -84,6 +99,34 @@ function buildCacheKey(params: {
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
slot: 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" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
}
return { enabled: true, selected: true };
}
function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
@@ -98,6 +141,9 @@ function resolveEnableState(
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
if (config.slots.memory === id) {
return { enabled: true };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
@@ -245,6 +291,9 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
});
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
let memorySlotMatched = false;
for (const candidate of discovery.candidates) {
const existingOrigin = seenIds.get(candidate.idHint);
@@ -321,6 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
record.kind = definition?.kind;
record.configSchema = Boolean(definition?.configSchema);
record.configUiHints =
definition?.configSchema &&
@@ -345,6 +395,30 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
>)
: undefined;
if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true;
}
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
const validatedConfig = validatePluginConfig({
schema: definition?.configSchema,
value: entry?.config,
@@ -409,6 +483,13 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (typeof memorySlot === "string" && !memorySlotMatched) {
registry.diagnostics.push({
level: "warn",
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
});
}
if (cacheEnabled) {
registryCache.set(cacheKey, registry);
}

View File

@@ -19,6 +19,7 @@ import type {
PluginDiagnostic,
PluginLogger,
PluginOrigin,
PluginKind,
} from "./types.js";
export type PluginToolRegistration = {
@@ -65,6 +66,7 @@ export type PluginRecord = {
name: string;
version?: string;
description?: string;
kind?: PluginKind;
source: string;
origin: PluginOrigin;
workspaceDir?: string;

View File

@@ -27,6 +27,8 @@ export type PluginConfigUiHint = {
placeholder?: string;
};
export type PluginKind = "memory";
export type PluginConfigValidation =
| { ok: true; value?: unknown }
| { ok: false; errors: string[] };
@@ -144,6 +146,7 @@ export type ClawdbotPluginDefinition = {
name?: string;
description?: string;
version?: string;
kind?: PluginKind;
configSchema?: ClawdbotPluginConfigSchema;
register?: (api: ClawdbotPluginApi) => void | Promise<void>;
activate?: (api: ClawdbotPluginApi) => void | Promise<void>;