From 9fd9f4c8962eb2f783db791cc0a62e290183cc77 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 02:12:01 +0000 Subject: [PATCH] feat(plugins): add memory slot plugin --- CHANGELOG.md | 8 ++ docs/cli/memory.md | 2 + docs/concepts/memory.md | 3 + docs/plugin.md | 19 +++++ extensions/memory-core/index.ts | 37 +++++++++ src/agents/clawdbot-tools.ts | 10 --- ...e-aliases-schemas-without-dropping.test.ts | 2 - src/cli/program/register.subclis.ts | 2 - src/config/schema.ts | 5 ++ src/config/types.plugins.ts | 6 ++ src/config/zod-schema.ts | 5 ++ src/plugins/loader.test.ts | 73 +++++++++++++++++ src/plugins/loader.ts | 81 +++++++++++++++++++ src/plugins/registry.ts | 2 + src/plugins/types.ts | 3 + 15 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 extensions/memory-core/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d148c28..ce823c7a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Docs: https://docs.clawd.bot +## 2026.1.18-3 + +### Changes +- Plugins: add exclusive plugin slots with a dedicated memory slot selector. +- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin. +- Docs: document plugin slots and memory plugin behavior. + ## 2026.1.18-2 ### Changes @@ -25,6 +32,7 @@ Docs: https://docs.clawd.bot - macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) - Tools: return a companion-app-required message when `system.run` is requested without a supporting node. - Discord: only emit slow listener warnings after 30s. + ## 2026.1.17-3 ### Changes diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 94cd933ac..5ccaa055e 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -8,9 +8,11 @@ read_when: # `clawdbot memory` Memory search tools (semantic memory status/index/search). +Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable). Related: - Memory concept: [Memory](/concepts/memory) + - Plugins: [Plugins](/plugins) ## Examples diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index d7c2b921c..6d7aaefb6 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -9,6 +9,9 @@ read_when: Clawdbot memory is **plain Markdown in the agent workspace**. The files are the source of truth; the model only "remembers" what gets written to disk. +Memory search tools are provided by the active memory plugin (default: +`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`. + ## Memory files (Markdown) The default workspace layout uses two memory layers: diff --git a/docs/plugin.md b/docs/plugin.md index cd0bbab95..f37cf233b 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -36,6 +36,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin. ## Available plugins (official) - Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams. +- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`) - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call` - [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser` - [Matrix](/channels/matrix) — `@clawdbot/matrix` @@ -137,6 +138,24 @@ Fields: Config changes **require a gateway restart**. +## Plugin slots (exclusive categories) + +Some plugin categories are **exclusive** (only one active at a time). Use +`plugins.slots` to select which plugin owns the slot: + +```json5 +{ + plugins: { + slots: { + memory: "memory-core" // or "none" to disable memory plugins + } + } +} +``` + +If multiple plugins declare `kind: "memory"`, only the selected one loads. Others +are disabled with diagnostics. + ## Control UI (schema + labels) The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms. diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts new file mode 100644 index 000000000..f6119188c --- /dev/null +++ b/extensions/memory-core/index.ts @@ -0,0 +1,37 @@ +import type { ClawdbotPluginApi } from "../../src/plugins/types.js"; + +import { createMemoryGetTool, createMemorySearchTool } from "../../src/agents/tools/memory-tool.js"; +import { registerMemoryCli } from "../../src/cli/memory-cli.js"; + +const memoryCorePlugin = { + id: "memory-core", + name: "Memory (Core)", + description: "File-backed memory search tools and CLI", + kind: "memory", + register(api: ClawdbotPluginApi) { + api.registerTool( + (ctx) => { + const memorySearchTool = createMemorySearchTool({ + config: ctx.config, + agentSessionKey: ctx.sessionKey, + }); + const memoryGetTool = createMemoryGetTool({ + config: ctx.config, + agentSessionKey: ctx.sessionKey, + }); + if (!memorySearchTool || !memoryGetTool) return null; + return [memorySearchTool, memoryGetTool]; + }, + { names: ["memory_search", "memory_get"] }, + ); + + api.registerCli( + ({ program }) => { + registerMemoryCli(program); + }, + { commands: ["memory"] }, + ); + }, +}; + +export default memoryCorePlugin; diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 1e3e970e4..12f1f37a0 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -9,7 +9,6 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; -import { createMemoryGetTool, createMemorySearchTool } from "./tools/memory-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; @@ -49,14 +48,6 @@ export function createClawdbotTools(options?: { sandboxRoot: options?.sandboxRoot, }) : null; - const memorySearchTool = createMemorySearchTool({ - config: options?.config, - agentSessionKey: options?.agentSessionKey, - }); - const memoryGetTool = createMemoryGetTool({ - config: options?.config, - agentSessionKey: options?.agentSessionKey, - }); const webSearchTool = createWebSearchTool({ config: options?.config, sandboxed: options?.sandboxed, @@ -119,7 +110,6 @@ export function createClawdbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), - ...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []), ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 5c8e3c0bd..497eb41a9 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -117,8 +117,6 @@ describe("createClawdbotCodingTools", () => { "sessions_send", "sessions_spawn", "session_status", - "memory_search", - "memory_get", "image", ]); const offenders: Array<{ diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 4ad155bfa..fee57b0a0 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -11,7 +11,6 @@ import { registerGatewayCli } from "../gateway-cli.js"; import { registerHooksCli } from "../hooks-cli.js"; import { registerWebhooksCli } from "../webhooks-cli.js"; import { registerLogsCli } from "../logs-cli.js"; -import { registerMemoryCli } from "../memory-cli.js"; import { registerModelsCli } from "../models-cli.js"; import { registerNodesCli } from "../nodes-cli.js"; import { registerPairingCli } from "../pairing-cli.js"; @@ -26,7 +25,6 @@ export function registerSubCliCommands(program: Command) { registerDaemonCli(program); registerGatewayCli(program); registerLogsCli(program); - registerMemoryCli(program); registerModelsCli(program); registerNodesCli(program); registerSandboxCli(program); diff --git a/src/config/schema.ts b/src/config/schema.ts index 2d098bd74..130acba75 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -277,6 +277,8 @@ const FIELD_LABELS: Record = { "plugins.allow": "Plugin Allowlist", "plugins.deny": "Plugin Denylist", "plugins.load.paths": "Plugin Load Paths", + "plugins.slots": "Plugin Slots", + "plugins.slots.memory": "Memory Plugin", "plugins.entries": "Plugin Entries", "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.config": "Plugin Config", @@ -413,6 +415,9 @@ const FIELD_HELP: Record = { "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", "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.', "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 e6cb7807d..dbe51f38e 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -3,6 +3,11 @@ export type PluginEntryConfig = { config?: Record; }; +export type PluginSlotsConfig = { + /** Select which plugin owns the memory slot ("none" disables memory plugins). */ + memory?: string; +}; + export type PluginsLoadConfig = { /** Additional plugin/extension paths to load. */ paths?: string[]; @@ -25,6 +30,7 @@ export type PluginsConfig = { /** Optional plugin denylist (plugin ids). */ deny?: string[]; load?: PluginsLoadConfig; + slots?: PluginSlotsConfig; entries?: Record; installs?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7532f5936..3533221b9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -324,6 +324,11 @@ export const ClawdbotSchema = z paths: z.array(z.string()).optional(), }) .optional(), + slots: z + .object({ + memory: z.string().optional(), + }) + .optional(), entries: z .record( z.string(), diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 21f26d595..270edf1b3 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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"); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 40b33a418..97ccfcb53 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -31,6 +31,9 @@ type NormalizedPluginsConfig = { allow: string[]; deny: string[]; loadPaths: string[]; + slots: { + memory?: string | null; + }; entries: Record }>; }; @@ -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(); + 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); } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index fa7a4f446..81010776c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 709700bed..ecd4425cd 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; activate?: (api: ClawdbotPluginApi) => void | Promise;