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.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 54c8a5361a7..c163f34e1a1 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,4 +1,29 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; + +export 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", @@ -6,6 +31,8 @@ export default definePluginEntry({ 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/package.json b/package.json index 99529029aed..86e2a3a1a88 100644 --- a/package.json +++ b/package.json @@ -309,6 +309,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 656dd6a72bb..33d0d7efa48 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -67,6 +67,7 @@ "json-store", "keyed-async-queue", "llm-task", + "memory-core", "memory-lancedb", "provider-auth", "provider-auth-api-key", 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..53a19d1861e --- /dev/null +++ b/src/memory/prompt-section.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + registerMemoryPromptSection, + buildMemoryPromptSection, + clearMemoryPromptSection, + _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"]); + }); + + 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/memory/prompt-section.ts b/src/memory/prompt-section.ts new file mode 100644 index 00000000000..a130340cfea --- /dev/null +++ b/src/memory/prompt-section.ts @@ -0,0 +1,42 @@ +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) ?? []; +} + +/** 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; +} + +/** @deprecated Use {@link clearMemoryPromptSection}. */ +export const _resetMemoryPromptSection = clearMemoryPromptSection; 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/loader.test.ts b/src/plugins/loader.test.ts index 8af6cf927d4..e2a3e14098e 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -23,12 +23,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, @@ -36,11 +37,13 @@ async function importFreshPluginTestModules() { ...hooks, ...runtime, ...registry, + ...promptSection, }; } const { __testing, + buildMemoryPromptSection, clearPluginLoaderCache, createHookRunner, createEmptyPluginRegistry, @@ -48,6 +51,7 @@ const { getActivePluginRegistryKey, getGlobalHookRunner, loadOpenClawPlugins, + registerMemoryPromptSection, resetGlobalHookRunner, setActivePluginRegistry, } = await importFreshPluginTestModules(); @@ -1204,6 +1208,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", @@ -3787,3 +3858,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([]); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6f5900f8334..aff828fd890 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -9,6 +9,11 @@ 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 { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { clearPluginCommands } from "./commands.js"; @@ -90,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", @@ -113,6 +123,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [ export function clearPluginLoaderCache(): void { registryCache.clear(); openAllowlistWarningCache.clear(); + clearMemoryPromptSection(); } const defaultLogger = () => createSubsystemLogger("plugins"); @@ -209,7 +220,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; @@ -220,11 +231,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) { @@ -763,18 +774,20 @@ 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; } } - // 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). @@ -1270,6 +1283,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); + const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder(); try { const result = register(api); @@ -1281,9 +1295,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, @@ -1317,7 +1336,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 2fdadfeb94d..04154435d30 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -8,6 +8,7 @@ import type { } 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"; @@ -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" 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; },