diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index bbd2a5b71a4a..8089cb757e45 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -92,6 +92,40 @@ describe("resolveMemoryWikiStatus", () => { expect(status.warnings.map((warning) => warning.code)).toContain("bridge-artifacts-missing"); }); + it("skips artifact enumeration when readMemoryArtifacts is disabled", async () => { + const config = resolveMemoryWikiConfig( + { + vaultMode: "bridge", + bridge: { + enabled: true, + readMemoryArtifacts: false, + }, + }, + { homedir: "/Users/tester" }, + ); + + let listCalls = 0; + const status = await resolveMemoryWikiStatus(config, { + appConfig: { + agents: { + list: [{ id: "main", default: true, workspace: "/tmp/workspace" }], + }, + } as OpenClawConfig, + listPublicArtifacts: async () => { + listCalls += 1; + return []; + }, + pathExists: async () => true, + resolveCommand: async () => null, + }); + + expect(listCalls).toBe(0); + expect(status.bridgePublicArtifactCount).toBeNull(); + expect(status.warnings.map((warning) => warning.code)).not.toContain( + "bridge-artifacts-missing", + ); + }); + it("discovers pages in nested subdirectories", async () => { const { rootDir, config } = await createVault({ prefix: "memory-wiki-nested-", diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 1d3908a28108..ef511eb6c209 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -211,7 +211,10 @@ export async function resolveMemoryWikiStatus( const exists = deps?.pathExists ?? pathExists; const vaultExists = await exists(config.vault.path); const bridgePublicArtifactCount = - deps?.appConfig && config.vaultMode === "bridge" && config.bridge.enabled + deps?.appConfig && + config.vaultMode === "bridge" && + config.bridge.enabled && + config.bridge.readMemoryArtifacts ? ( await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({ cfg: deps.appConfig, diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index 5382aa53b9c0..cf3973ed2c30 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -234,6 +234,58 @@ describe("memory plugin state", () => { ]); }); + it("drops malformed public memory artifacts instead of crashing the sort", async () => { + // Record-shaped artifact as shipped by @mem0/openclaw-mem0 <= 1.0.14 — + // none of the file-backed fields the sort dereferences. + const recordShapedArtifact = { + id: "mem0:memory:1", + type: "memory", + title: "A memory", + content: "memory text", + } as unknown as MemoryPluginPublicArtifact; + + registerMemoryCapability("openclaw-mem0", { + publicArtifacts: { + async listArtifacts() { + return [ + recordShapedArtifact, + { + kind: "memory-root", + workspaceDir: "/tmp/workspace", + relativePath: "MEMORY.md", + absolutePath: "/tmp/workspace/MEMORY.md", + agentIds: ["main"], + contentType: "markdown" as const, + }, + ]; + }, + }, + }); + + await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([ + { + kind: "memory-root", + workspaceDir: "/tmp/workspace", + relativePath: "MEMORY.md", + absolutePath: "/tmp/workspace/MEMORY.md", + agentIds: ["main"], + contentType: "markdown", + }, + ]); + }); + + it("ignores a non-array public artifact listing", async () => { + registerMemoryCapability("openclaw-mem0", { + publicArtifacts: { + async listArtifacts() { + return { artifacts: [] } as unknown as MemoryPluginPublicArtifact[]; + }, + }, + }); + + await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([]); + }); + it("preserves sidecar runtime fields when a memory plugin adds public artifacts only", async () => { const runtime = createMemoryRuntime(); const flushPlanResolver = () => createMemoryFlushPlan("memory/sidecar.md"); diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 1c21db827b6c..121c7914f6cb 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -1,8 +1,11 @@ /** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */ import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import type { MemorySearchManager } from "../memory-host-sdk/host/types.js"; +const log = createSubsystemLogger("plugins/memory-state"); + export type MemoryPromptSectionBuilder = (params: { availableTools: Set; citationsMode?: MemoryCitationsMode; @@ -317,11 +320,36 @@ function cloneMemoryPublicArtifact( }; } +// The sort below dereferences these fields, so a plugin-supplied artifact +// missing any of them would crash every status/bridge consumer. +function isValidMemoryPublicArtifact( + artifact: MemoryPluginPublicArtifact | null | undefined, +): artifact is MemoryPluginPublicArtifact { + return ( + typeof artifact?.kind === "string" && + typeof artifact.workspaceDir === "string" && + typeof artifact.relativePath === "string" && + typeof artifact.absolutePath === "string" && + typeof artifact.contentType === "string" + ); +} + export async function listActiveMemoryPublicArtifacts(params: { cfg: OpenClawConfig; }): Promise { - const artifacts = + const pluginId = memoryPluginState.capability?.pluginId; + const listed = (await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? []; + if (!Array.isArray(listed)) { + log.warn(`ignoring public memory artifacts from plugin "${pluginId}": not an array`); + return []; + } + const artifacts = listed.filter(isValidMemoryPublicArtifact); + if (artifacts.length < listed.length) { + log.warn( + `ignoring ${listed.length - artifacts.length} malformed public memory artifact(s) from plugin "${pluginId}": artifacts must include string kind, workspaceDir, relativePath, absolutePath, and contentType`, + ); + } return artifacts.map(cloneMemoryPublicArtifact).toSorted((left, right) => { const workspaceOrder = left.workspaceDir.localeCompare(right.workspaceDir); if (workspaceOrder !== 0) {