From 02222b93c554444e7b8b19c2c6dc1198f83a7f5f Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 8 Mar 2026 11:30:34 +0200 Subject: [PATCH] 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; },