From 02222b93c554444e7b8b19c2c6dc1198f83a7f5f Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 8 Mar 2026 11:30:34 +0200 Subject: [PATCH 1/9] feat(memory): delegate system prompt section to active memory plugin Add registerMemoryPromptSection to the plugin API so the active memory plugin provides its own system prompt builder callback. The built-in memory-core plugin registers the existing Memory Recall section via this new API. When no memory plugin is active (or a plugin does not register a builder), the memory section is simply omitted from the system prompt. This lets third-party memory plugins inject their own guidance at the same position in the prompt. --- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/memory-core/index.ts | 24 ++++++++++ src/agents/system-prompt.ts | 23 ++------- src/memory/prompt-section.test.ts | 52 +++++++++++++++++++++ src/memory/prompt-section.ts | 29 ++++++++++++ src/plugin-sdk/memory-core.ts | 1 + src/plugins/types.ts | 4 ++ test/helpers/extensions/plugin-api.ts | 1 + 8 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 src/memory/prompt-section.test.ts create mode 100644 src/memory/prompt-section.ts diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 778cb695d88..5ca3305953e 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -54,6 +54,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerHttpRoute() {}, registerCommand() {}, registerContextEngine() {}, + registerMemoryPromptSection() {}, on() {}, resolvePath: (p) => p, ...overrides, diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 54c8a5361a7..7ea7116c08f 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,11 +1,35 @@ +import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; import { definePluginEntry } from "openclaw/plugin-sdk/core"; +const buildPromptSection: MemoryPromptSectionBuilder = ({ availableTools, citationsMode }) => { + if (!availableTools.has("memory_search") && !availableTools.has("memory_get")) { + return []; + } + const lines = [ + "## Memory Recall", + "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", + ]; + if (citationsMode === "off") { + lines.push( + "Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.", + ); + } else { + lines.push( + "Citations: include Source: when it helps the user verify memory snippets.", + ); + } + lines.push(""); + return lines; +}; + export default definePluginEntry({ id: "memory-core", name: "Memory (Core)", description: "File-backed memory search tools and CLI", kind: "memory", register(api) { + api.registerMemoryPromptSection(buildPromptSection); + api.registerTool( (ctx) => { const memorySearchTool = api.runtime.tools.createMemorySearchTool({ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3ee438db2d4..09074c28803 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -2,6 +2,7 @@ import { createHmac, createHash } from "node:crypto"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; +import { buildMemoryPromptSection } from "../memory/prompt-section.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; @@ -43,24 +44,10 @@ function buildMemorySection(params: { if (params.isMinimal) { return []; } - if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) { - return []; - } - const lines = [ - "## Memory Recall", - "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", - ]; - if (params.citationsMode === "off") { - lines.push( - "Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.", - ); - } else { - lines.push( - "Citations: include Source: when it helps the user verify memory snippets.", - ); - } - lines.push(""); - return lines; + return buildMemoryPromptSection({ + availableTools: params.availableTools, + citationsMode: params.citationsMode, + }); } function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) { diff --git a/src/memory/prompt-section.test.ts b/src/memory/prompt-section.test.ts new file mode 100644 index 00000000000..b537447b844 --- /dev/null +++ b/src/memory/prompt-section.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + registerMemoryPromptSection, + buildMemoryPromptSection, + _resetMemoryPromptSection, +} from "./prompt-section.js"; + +describe("memory prompt section registry", () => { + beforeEach(() => { + _resetMemoryPromptSection(); + }); + + it("returns empty array when no builder is registered", () => { + const result = buildMemoryPromptSection({ + availableTools: new Set(["memory_search", "memory_get"]), + }); + expect(result).toEqual([]); + }); + + it("delegates to the registered builder", () => { + registerMemoryPromptSection(({ availableTools }) => { + if (!availableTools.has("memory_search")) return []; + return ["## Custom Memory", "Use custom memory tools.", ""]; + }); + + const result = buildMemoryPromptSection({ + availableTools: new Set(["memory_search"]), + }); + expect(result).toEqual(["## Custom Memory", "Use custom memory tools.", ""]); + }); + + it("passes citationsMode to the builder", () => { + registerMemoryPromptSection(({ citationsMode }) => { + return [`citations: ${citationsMode ?? "default"}`]; + }); + + expect( + buildMemoryPromptSection({ + availableTools: new Set(), + citationsMode: "off", + }), + ).toEqual(["citations: off"]); + }); + + it("last registration wins", () => { + registerMemoryPromptSection(() => ["first"]); + registerMemoryPromptSection(() => ["second"]); + + const result = buildMemoryPromptSection({ availableTools: new Set() }); + expect(result).toEqual(["second"]); + }); +}); diff --git a/src/memory/prompt-section.ts b/src/memory/prompt-section.ts new file mode 100644 index 00000000000..322d040312a --- /dev/null +++ b/src/memory/prompt-section.ts @@ -0,0 +1,29 @@ +import type { MemoryCitationsMode } from "../config/types.memory.js"; + +/** + * Callback that the active memory plugin provides to build + * its section of the agent system prompt. + */ +export type MemoryPromptSectionBuilder = (params: { + availableTools: Set; + citationsMode?: MemoryCitationsMode; +}) => string[]; + +// Module-level singleton — only one memory plugin can be active (exclusive slot). +let _builder: MemoryPromptSectionBuilder | undefined; + +export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void { + _builder = builder; +} + +export function buildMemoryPromptSection(params: { + availableTools: Set; + citationsMode?: MemoryCitationsMode; +}): string[] { + return _builder?.(params) ?? []; +} + +/** Reset state (for tests). */ +export function _resetMemoryPromptSection(): void { + _builder = undefined; +} diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index b715c1f50ca..2f9c37482d2 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -2,4 +2,5 @@ // Keep this list additive and scoped to symbols used under extensions/memory-core. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export type { MemoryPromptSectionBuilder } from "../memory/prompt-section.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 343a338c4f8..ef7d98fddbc 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1341,6 +1341,10 @@ export type OpenClawPluginApi = { id: string, factory: import("../context-engine/registry.js").ContextEngineFactory, ) => void; + /** Register the system prompt section builder for this memory plugin (exclusive slot). */ + registerMemoryPromptSection: ( + builder: import("../memory/prompt-section.js").MemoryPromptSectionBuilder, + ) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( diff --git a/test/helpers/extensions/plugin-api.ts b/test/helpers/extensions/plugin-api.ts index ee1e97178a8..9fe8ac80ed3 100644 --- a/test/helpers/extensions/plugin-api.ts +++ b/test/helpers/extensions/plugin-api.ts @@ -23,6 +23,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi onConversationBindingResolved() {}, registerCommand() {}, registerContextEngine() {}, + registerMemoryPromptSection() {}, resolvePath(input: string) { return input; }, From d827603bc90455da0e7fb020e2126c2ccf64a37a Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 8 Mar 2026 11:30:45 +0200 Subject: [PATCH 2/9] feat(memory): restrict registerMemoryPromptSection to memory plugins Throw if a non-memory plugin attempts to register a memory prompt section builder, enforcing the exclusive slot constraint at the API level. --- src/plugins/registry.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2fdadfeb94d..b4007ff6067 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { registerContextEngineForOwner } from "../context-engine/registry.js"; +import { registerMemoryPromptSection } from "../memory/prompt-section.js"; import type { GatewayRequestHandler, GatewayRequestHandlers, @@ -979,6 +980,21 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); } }, + registerMemoryPromptSection: (builder) => { + if (registrationMode !== "full") { + return; + } + if (record.kind !== "memory") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "only memory plugins can register a memory prompt section", + }); + return; + } + registerMemoryPromptSection(builder); + }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registrationMode === "full" From 586326158efbd085b18cc411270eaed23e2878b3 Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Mon, 9 Mar 2026 14:33:58 +0200 Subject: [PATCH 3/9] fix(plugins): clear memory prompt section on plugin reload --- src/memory/prompt-section.ts | 7 +++++-- src/plugins/loader.ts | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/memory/prompt-section.ts b/src/memory/prompt-section.ts index 322d040312a..e1fe0470b8d 100644 --- a/src/memory/prompt-section.ts +++ b/src/memory/prompt-section.ts @@ -23,7 +23,10 @@ export function buildMemoryPromptSection(params: { return _builder?.(params) ?? []; } -/** Reset state (for tests). */ -export function _resetMemoryPromptSection(): void { +/** Clear the registered builder (called on plugin reload and in tests). */ +export function clearMemoryPromptSection(): void { _builder = undefined; } + +/** @deprecated Use {@link clearMemoryPromptSection}. */ +export const _resetMemoryPromptSection = clearMemoryPromptSection; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03a1b0810ff..b4b9e8edb1e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,6 +10,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; +import { clearMemoryPromptSection } from "../memory/prompt-section.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { clearPluginCommands } from "./commands.js"; import { @@ -113,6 +114,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [ export function clearPluginLoaderCache(): void { registryCache.clear(); openAllowlistWarningCache.clear(); + clearMemoryPromptSection(); } const defaultLogger = () => createSubsystemLogger("plugins"); @@ -739,11 +741,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - // Clear previously registered plugin commands before reloading. + // Clear previously registered plugin state before reloading. // Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins. if (shouldActivate) { clearPluginCommands(); clearPluginInteractiveHandlers(); + clearMemoryPromptSection(); } // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). From df5f2cb77f0666f0db1578e956331f49bd51ba0f Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Mon, 9 Mar 2026 15:42:51 +0200 Subject: [PATCH 4/9] fix(plugins): restore memory prompt builder on cached plugin loads --- src/memory/prompt-section.ts | 10 ++++++++++ src/plugins/loader.ts | 29 +++++++++++++++++++++-------- src/plugins/registry.ts | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/memory/prompt-section.ts b/src/memory/prompt-section.ts index e1fe0470b8d..a130340cfea 100644 --- a/src/memory/prompt-section.ts +++ b/src/memory/prompt-section.ts @@ -23,6 +23,16 @@ export function buildMemoryPromptSection(params: { return _builder?.(params) ?? []; } +/** Return the current builder (used by the plugin cache to snapshot state). */ +export function getMemoryPromptSectionBuilder(): MemoryPromptSectionBuilder | undefined { + return _builder; +} + +/** Restore a previously-snapshotted builder (used on plugin cache hits). */ +export function restoreMemoryPromptSection(builder: MemoryPromptSectionBuilder | undefined): void { + _builder = builder; +} + /** Clear the registered builder (called on plugin reload and in tests). */ export function clearMemoryPromptSection(): void { _builder = undefined; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b4b9e8edb1e..e0d5374a6d8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -9,8 +9,12 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + clearMemoryPromptSection, + getMemoryPromptSectionBuilder, + restoreMemoryPromptSection, +} from "../memory/prompt-section.js"; import { resolveUserPath } from "../utils.js"; -import { clearMemoryPromptSection } from "../memory/prompt-section.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { clearPluginCommands } from "./commands.js"; import { @@ -91,8 +95,13 @@ export class PluginLoadFailureError extends Error { } } +type CachedPluginState = { + registry: PluginRegistry; + memoryPromptBuilder: ReturnType; +}; + const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; -const registryCache = new Map(); +const registryCache = new Map(); const openAllowlistWarningCache = new Set(); const LAZY_RUNTIME_REFLECTION_KEYS = [ "version", @@ -180,7 +189,7 @@ export const __testing = { maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, }; -function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined { +function getCachedPluginRegistry(cacheKey: string): CachedPluginState | undefined { const cached = registryCache.get(cacheKey); if (!cached) { return undefined; @@ -191,11 +200,11 @@ function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined { return cached; } -function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void { +function setCachedPluginRegistry(cacheKey: string, state: CachedPluginState): void { if (registryCache.has(cacheKey)) { registryCache.delete(cacheKey); } - registryCache.set(cacheKey, registry); + registryCache.set(cacheKey, state); while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) { const oldestKey = registryCache.keys().next().value; if (!oldestKey) { @@ -734,10 +743,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { + restoreMemoryPromptSection(cached.memoryPromptBuilder); if (shouldActivate) { - activatePluginRegistry(cached, cacheKey); + activatePluginRegistry(cached.registry, cacheKey); } - return cached; + return cached.registry; } } @@ -1289,7 +1299,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi maybeThrowOnPluginLoadError(registry, options.throwOnLoadError); if (cacheEnabled) { - setCachedPluginRegistry(cacheKey, registry); + setCachedPluginRegistry(cacheKey, { + registry, + memoryPromptBuilder: getMemoryPromptSectionBuilder(), + }); } if (shouldActivate) { activatePluginRegistry(registry, cacheKey); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b4007ff6067..04154435d30 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2,13 +2,13 @@ import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { registerContextEngineForOwner } from "../context-engine/registry.js"; -import { registerMemoryPromptSection } from "../memory/prompt-section.js"; import type { GatewayRequestHandler, GatewayRequestHandlers, } from "../gateway/server-methods/types.js"; import { registerInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; +import { registerMemoryPromptSection } from "../memory/prompt-section.js"; import { resolveUserPath } from "../utils.js"; import { registerPluginCommand, validatePluginCommandDefinition } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; From a8fde89a2ec4abcf342382138021e6281e5ec872 Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Wed, 18 Mar 2026 10:37:42 +0200 Subject: [PATCH 5/9] fix(memory): reset memory prompt section on plugin cache clear --- src/memory/prompt-section.test.ts | 14 +++++++++++++- src/plugins/loader.test.ts | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/memory/prompt-section.test.ts b/src/memory/prompt-section.test.ts index b537447b844..53a19d1861e 100644 --- a/src/memory/prompt-section.test.ts +++ b/src/memory/prompt-section.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { registerMemoryPromptSection, buildMemoryPromptSection, + clearMemoryPromptSection, _resetMemoryPromptSection, } from "./prompt-section.js"; @@ -19,7 +20,9 @@ describe("memory prompt section registry", () => { it("delegates to the registered builder", () => { registerMemoryPromptSection(({ availableTools }) => { - if (!availableTools.has("memory_search")) return []; + if (!availableTools.has("memory_search")) { + return []; + } return ["## Custom Memory", "Use custom memory tools.", ""]; }); @@ -49,4 +52,13 @@ describe("memory prompt section registry", () => { const result = buildMemoryPromptSection({ availableTools: new Set() }); expect(result).toEqual(["second"]); }); + + it("clearMemoryPromptSection resets the builder", () => { + registerMemoryPromptSection(() => ["stale section"]); + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["stale section"]); + + clearMemoryPromptSection(); + + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); + }); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a4bf12fad15..0336f2fb14d 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -22,12 +22,13 @@ async function importFreshPluginTestModules() { vi.doUnmock("./hooks.js"); vi.doUnmock("./loader.js"); vi.doUnmock("jiti"); - const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ + const [loader, hookRunnerGlobal, hooks, runtime, registry, promptSection] = await Promise.all([ import("./loader.js"), import("./hook-runner-global.js"), import("./hooks.js"), import("./runtime.js"), import("./registry.js"), + import("../memory/prompt-section.js"), ]); return { ...loader, @@ -35,11 +36,13 @@ async function importFreshPluginTestModules() { ...hooks, ...runtime, ...registry, + ...promptSection, }; } const { __testing, + buildMemoryPromptSection, clearPluginLoaderCache, createHookRunner, createEmptyPluginRegistry, @@ -47,6 +50,7 @@ const { getActivePluginRegistryKey, getGlobalHookRunner, loadOpenClawPlugins, + registerMemoryPromptSection, resetGlobalHookRunner, setActivePluginRegistry, } = await importFreshPluginTestModules(); @@ -3674,3 +3678,16 @@ export const runtimeValue = helperValue;`, expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); }); + +describe("clearPluginLoaderCache", () => { + it("resets the registered memory prompt section builder", () => { + registerMemoryPromptSection(() => ["stale memory section"]); + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([ + "stale memory section", + ]); + + clearPluginLoaderCache(); + + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); + }); +}); From f9744da64d414b5e6ca626910a470d1ae1cb2284 Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Wed, 18 Mar 2026 10:43:21 +0200 Subject: [PATCH 6/9] style: fix import order in memory-core --- extensions/memory-core/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 7ea7116c08f..e176ea619fe 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,5 +1,5 @@ -import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; const buildPromptSection: MemoryPromptSectionBuilder = ({ availableTools, citationsMode }) => { if (!availableTools.has("memory_search") && !availableTools.has("memory_get")) { From 6f8f78c6529c04a5a7f6e5d86e72c038071077cd Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Wed, 18 Mar 2026 11:22:35 +0200 Subject: [PATCH 7/9] test(memory-core): add buildPromptSection unit tests --- extensions/memory-core/index.test.ts | 32 ++++++++++++++++++++++++++++ extensions/memory-core/index.ts | 5 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 extensions/memory-core/index.test.ts diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts new file mode 100644 index 00000000000..8c81f52fc05 --- /dev/null +++ b/extensions/memory-core/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildPromptSection } from "./index.js"; + +describe("buildPromptSection", () => { + it("returns empty when no memory tools are available", () => { + expect(buildPromptSection({ availableTools: new Set() })).toEqual([]); + }); + + it("returns Memory Recall section when memory_search is available", () => { + const result = buildPromptSection({ availableTools: new Set(["memory_search"]) }); + expect(result[0]).toBe("## Memory Recall"); + expect(result).toContain( + "Citations: include Source: when it helps the user verify memory snippets.", + ); + expect(result.at(-1)).toBe(""); + }); + + it("returns Memory Recall section when memory_get is available", () => { + const result = buildPromptSection({ availableTools: new Set(["memory_get"]) }); + expect(result[0]).toBe("## Memory Recall"); + }); + + it("includes citations-off instruction when citationsMode is off", () => { + const result = buildPromptSection({ + availableTools: new Set(["memory_search"]), + citationsMode: "off", + }); + expect(result).toContain( + "Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.", + ); + }); +}); diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index e176ea619fe..c163f34e1a1 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,7 +1,10 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; -const buildPromptSection: MemoryPromptSectionBuilder = ({ availableTools, citationsMode }) => { +export const buildPromptSection: MemoryPromptSectionBuilder = ({ + availableTools, + citationsMode, +}) => { if (!availableTools.has("memory_search") && !availableTools.has("memory_get")) { return []; } From 800b74e365deea7fa31583a33292ff1b187ca2e8 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 20 Mar 2026 06:53:49 -0700 Subject: [PATCH 8/9] Plugins: preserve memory prompt reload state --- src/plugins/loader.test.ts | 67 ++++++++++++++++++++++++++++++++++++++ src/plugins/loader.ts | 6 ++++ 2 files changed, 73 insertions(+) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0336f2fb14d..75d08a32676 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1189,6 +1189,73 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip clearPluginCommands(); }); + it("does not replace the active memory prompt section during non-activating loads", () => { + useNoBundledPlugins(); + registerMemoryPromptSection(() => ["active memory section"]); + const plugin = writePlugin({ + id: "snapshot-memory", + filename: "snapshot-memory.cjs", + body: `module.exports = { + id: "snapshot-memory", + kind: "memory", + register(api) { + api.registerMemoryPromptSection(() => ["snapshot memory section"]); + }, + };`, + }); + + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["snapshot-memory"], + slots: { memory: "snapshot-memory" }, + }, + }, + onlyPluginIds: ["snapshot-memory"], + }); + + expect(scoped.plugins.find((entry) => entry.id === "snapshot-memory")?.status).toBe("loaded"); + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([ + "active memory section", + ]); + }); + + it("clears a newly-registered memory prompt section when plugin register fails", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "failing-memory", + filename: "failing-memory.cjs", + body: `module.exports = { + id: "failing-memory", + kind: "memory", + register(api) { + api.registerMemoryPromptSection(() => ["stale failure section"]); + throw new Error("memory register failed"); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["failing-memory"], + slots: { memory: "failing-memory" }, + }, + }, + onlyPluginIds: ["failing-memory"], + }); + + expect(registry.plugins.find((entry) => entry.id === "failing-memory")?.status).toBe("error"); + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); + }); + it("throws when activate:false is used without cache:false", () => { expect(() => loadOpenClawPlugins({ activate: false })).toThrow( "activate:false requires cache:false", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index e0d5374a6d8..e0a0d746e1c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1252,6 +1252,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); + const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder(); try { const result = register(api); @@ -1263,9 +1264,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi message: "plugin register returned a promise; async registration is ignored", }); } + // Snapshot loads should not replace process-global runtime prompt state. + if (!shouldActivate) { + restoreMemoryPromptSection(previousMemoryPromptBuilder); + } registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); } catch (err) { + restoreMemoryPromptSection(previousMemoryPromptBuilder); recordPluginError({ logger, registry, From 80cc0b9c07d7ded99451647d0c0b5d000f03f271 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 20 Mar 2026 06:55:12 -0700 Subject: [PATCH 9/9] Plugin SDK: export memory-core subpath --- package.json | 4 ++++ scripts/lib/plugin-sdk-entrypoints.json | 1 + 2 files changed, 5 insertions(+) diff --git a/package.json b/package.json index ed8cc402625..fb9b3f5849f 100644 --- a/package.json +++ b/package.json @@ -377,6 +377,10 @@ "types": "./dist/plugin-sdk/llm-task.d.ts", "default": "./dist/plugin-sdk/llm-task.js" }, + "./plugin-sdk/memory-core": { + "types": "./dist/plugin-sdk/memory-core.d.ts", + "default": "./dist/plugin-sdk/memory-core.js" + }, "./plugin-sdk/memory-lancedb": { "types": "./dist/plugin-sdk/memory-lancedb.d.ts", "default": "./dist/plugin-sdk/memory-lancedb.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f9c20590e4b..aa75099a251 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -84,6 +84,7 @@ "keyed-async-queue", "line", "llm-task", + "memory-core", "memory-lancedb", "minimax-portal-auth", "provider-auth",