diff --git a/openclaw/index.test.ts b/openclaw/index.test.ts index 3adf1666df..aaee17e0ff 100644 --- a/openclaw/index.test.ts +++ b/openclaw/index.test.ts @@ -14,6 +14,7 @@ import { isGenericAssistantMessage, stripNoiseFromContent, filterMessagesForExtraction, + isCliMetadataPass, } from "./index.ts"; // --------------------------------------------------------------------------- @@ -610,3 +611,27 @@ describe("auto-recall threshold respects cfg.searchThreshold", () => { expect(filtered).toHaveLength(6); }); }); + +// --------------------------------------------------------------------------- +// isCliMetadataPass — guards the duplicate register() pass (issue #5371) +// --------------------------------------------------------------------------- +describe("isCliMetadataPass", () => { + it("returns true on the cli-metadata registration pass", () => { + expect(isCliMetadataPass("cli-metadata")).toBe(true); + }); + + it("returns false on the full registration pass", () => { + expect(isCliMetadataPass("full")).toBe(false); + }); + + it("returns false when registrationMode is undefined (older cores)", () => { + expect(isCliMetadataPass(undefined)).toBe(false); + }); + + it("returns false for unknown/other modes", () => { + expect(isCliMetadataPass("")).toBe(false); + expect(isCliMetadataPass(null)).toBe(false); + expect(isCliMetadataPass("Cli-Metadata")).toBe(false); + expect(isCliMetadataPass(0)).toBe(false); + }); +}); diff --git a/openclaw/index.ts b/openclaw/index.ts index 4b4c890414..3a6f9a1e13 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -87,6 +87,25 @@ export { createProvider } from "./providers.ts"; // Helpers // ============================================================================ +/** + * The OpenClaw plugin loader invokes `register()` twice per gateway startup: + * once in the main pass (`registrationMode: "full"`) and once in a metadata + * pass (`registrationMode: "cli-metadata"`) used only to collect CLI command + * metadata for `openclaw mem0 help` and similar commands. + * + * On the metadata pass we must still register CLI commands, but everything + * else (provider/backend construction, memory capability, service, lifecycle + * hooks, telemetry, and the "registered" info log) should be skipped so those + * side effects only run once. Older cores that do not set `registrationMode` + * pass `undefined`, which we treat as a full registration for backwards + * compatibility. + * + * See https://github.com/mem0ai/mem0/issues/5371 + */ +export function isCliMetadataPass(registrationMode: unknown): boolean { + return registrationMode === "cli-metadata"; +} + // ============================================================================ // Plugin Definition // ============================================================================ @@ -108,6 +127,27 @@ const memoryPlugin = definePluginEntry({ }; const cfg = mem0ConfigSchema.parse(api.pluginConfig, fileConfig); + // The core loader calls register() twice: a "full" pass and a + // "cli-metadata" pass. The metadata pass only needs the CLI surface so + // `openclaw mem0 help` can enumerate commands. Register CLI commands and + // return early to avoid constructing a second provider/backend, firing + // duplicate telemetry, double-registering the service/capability, and + // emitting a duplicate "registered" log line. See issue #5371. + if (isCliMetadataPass(api.registrationMode)) { + registerCliCommands( + api, + null as any, + null as any, + cfg, + () => cfg.userId, + (id: string) => `${cfg.userId}:agent:${id}`, + () => ({ user_id: cfg.userId, top_k: cfg.topK }), + () => undefined, + () => undefined, + ); + return; + } + // Telemetry context bound to this plugin instance's config const telemetryCtx = { apiKey: cfg.apiKey, diff --git a/openclaw/openclaw-plugin-sdk.d.ts b/openclaw/openclaw-plugin-sdk.d.ts index 7484e4813f..87fb8fe054 100644 --- a/openclaw/openclaw-plugin-sdk.d.ts +++ b/openclaw/openclaw-plugin-sdk.d.ts @@ -26,6 +26,15 @@ declare module "openclaw/plugin-sdk" { export interface OpenClawPluginApi { pluginConfig: Record; + /** + * Which registration pass the core plugin loader is running. + * The loader calls `register()` twice: once with "full" (the real + * registration) and once with "cli-metadata" (to collect CLI command + * metadata for `openclaw help`). Plugins should skip heavy, + * non-CLI side effects on the "cli-metadata" pass. Older cores leave + * this undefined, which is treated as a full registration. + */ + registrationMode?: "full" | "cli-metadata" | string; logger: { info(msg: string): void; warn(msg: string): void;